Containerise a Grails 8 App with Spring Boot bootBuildImage
Package a Grails 8 application as a production-ready OCI image using Spring Boot 4 bootBuildImage and Paketo Buildpacks, then orchestrate it with PostgreSQL via Docker Compose. A hand-rolled multi-stage Dockerfile is shown as the alternative path.
Authors: James Fredley
Grails Version: 8
1 Getting Started
In this guide you will package an Apache Grails 8 application as a production-ready OCI container image and orchestrate it alongside a real PostgreSQL database with Docker Compose.
The path most teams should pick is Spring Boot 4’s bootBuildImage Gradle task, which delegates to Paketo Buildpacks and produces a minimal, layered, reproducible image with no Dockerfile to maintain. We will lead with that.
For teams that need to control every layer, this guide also covers a hand-rolled multi-stage Dockerfile with a non-root user, and shows how the two paths produce equivalent runtime behaviour.
This guide targets Apache Grails 8 / Spring Boot 4. It supersedes the earlier Grails as a Docker Container guide for Grails 3 and 4, which used the now-deprecated gradle-docker plugin.
1.1 What You Will Build
By the end of the guide you will have:
-
A
./gradlew bootBuildImagetask wired up so a single command produces a minimal OCI image taggedghcr.io/grails-guides/grails-docker-bootbuildimage:<version>- no Dockerfile, no buildscript, just configuration. -
Container-friendly JVM flags (
-XX:+UseContainerSupport,-XX:MaxRAMPercentage=75.0) baked into the image via Paketo’s Java Tool Options buildpack, so the JVM picks up the cgroup memory limit and right-sizes its heap. -
An
application.ymlthat resolves its datasource URL, username, and password from environment variables, with sensible development defaults so./gradlew bootRunstill works against a local Postgres. -
A
compose.ymlstack that runs the image alongsidepostgres:16-alpine, with a Spring Boot Actuator-drivenHEALTHCHECKand adepends_on: condition: service_healthyblock so the app waits for the database to be ready before it starts. -
An equivalent multi-stage
Dockerfile(build stage onbellsoft/liberica-openjdk-debian:21, runtime stage on the matching JRE image) that runs the app as UID10001and shows what the buildpack does for you when you let it. -
The commands you need to push the image to GitHub Container Registry, and a one-paragraph note on consuming it from the matching CI/CD guide.
The official Grails 8 reference manual at grails-doc/src/en/guide/deployment.adoc (and the deployment/ subdirectory) covers WAR file deployment to a servlet container. This guide complements that by walking the modern OCI-image path that Spring Boot 4’s bootBuildImage task makes possible.
Files touched in this chapter:
-
None
1.2 What You Will Need
To complete this guide you will need:
-
JDK 21
-
Docker Engine 24+ (or Docker Desktop 4.30+);
docker buildxanddocker composeare bundled -
About 4 GB of free disk for the buildpack base images on first run
-
About 45 minutes
1.3 How to Complete the Guide
You can either type the code in this guide as you read or skip ahead and clone the finished sample app:
git clone -b grails8 https://github.com/grails-guides/grails-docker-bootbuildimage.git
cd grails-docker-bootbuildimage/complete
./gradlew bootBuildImage
docker compose up
The repository contains two top-level directories:
-
initial/- the starting Grails 8 project, generated from start.grails.org with thepostgres,testcontainers, anddatabase-migrationfeatures and no further customisations. -
complete/- the same project with all Docker-related changes from this guide already in place.
Each chapter ends with the file paths it touches relative to the project root, so you can match what you typed against the complete/ reference.
2 Creating the Application
Generate a fresh Apache Grails 8 web application from start.grails.org with the three features the rest of the guide expects: postgres (the JDBC driver and a Postgres datasource template), testcontainers (so we can spin up a real Postgres in functional tests), and database-migration (Liquibase, so the production image can run schema changes on first start).
2.1 Download a Grails 8 Starter
Open start.grails.org, pick the web profile, name the application docker, set the package to example, choose JDK 21, tick the postgres, testcontainers, and database-migration features, and download the generated zip. Unzip it and cd into the docker directory.
The forge unpacks into a docker/ directory containing a vanilla Grails 8 layout plus the three feature-driven additions:
-
runtimeOnly "org.postgresql:postgresql"and a Postgres datasource template ingrails-app/conf/application.yml -
testImplementation "org.testcontainers:postgresql"(andorg.testcontainers:spock+:testcontainers) -
implementation "org.apache.grails:grails-data-hibernate5-dbmigration"
|
The forge’s default |
3 Two Paths to a Container Image
Two paths produce a runnable container image from the same bootJar:
-
Spring Boot 4’s
bootBuildImagetask invokes Paketo Buildpacks under the hood. The buildpack inspects your jar, picks a JRE, lays the application out across cacheable image layers (dependencies, snapshot dependencies, classes, resources), and writes the result straight into the local Docker daemon. There is no Dockerfile, no buildscript, nothing for you to maintain. Updates to the JRE, base image, and CVE patches happen by upgrading the Spring Boot version, not by editing your repo. -
A hand-rolled multi-stage Dockerfile gives you total control over the base image, the user/uid, the layer ordering, and exactly which files are copied. The cost is that you own all those decisions, including keeping the base images patched.
For a typical Grails 8 web app the buildpack path wins on every dimension that matters: smaller images (the buildpack’s slim JRE base is ~150 MB), better layering (a code-only change touches only the top layer), and a free non-root user. The Dockerfile path is justified when you have a regulated build that must consume an internal mirror, an air-gapped CI, or a non-buildpack-compatible base image.
This guide leads with bootBuildImage and treats the Dockerfile as an alternative.
Files touched in this chapter:
-
None
4 The Spring Boot bootBuildImage Task
The Spring Boot Gradle plugin (already on the classpath via the Grails web plugin) registers a bootBuildImage task out of the box. The default image name is docker.io/library/<rootProject.name>:<version>, which is rarely what you want. Override it - and pin the JVM version - with a small block at the bottom of build.gradle:
def imageNameOverride = providers.gradleProperty('imageName')
.getOrElse("ghcr.io/grails-guides/grails-docker-bootbuildimage:${version}")
tasks.named('bootBuildImage') {
imageName = imageNameOverride
environment = [
'BP_JVM_VERSION' : '21',
'BPE_DELIM_JAVA_TOOL_OPTIONS' : ' ',
'BPE_APPEND_JAVA_TOOL_OPTIONS' : '-XX:MaxRAMPercentage=75.0 -XX:+UseContainerSupport'
]
}
Three pieces are doing the work:
-
imageNameaccepts a fully-qualified registry path. We default toghcr.io/grails-guides/grails-docker-bootbuildimage:<version>so the image is ready to push to GitHub Container Registry, but allow-PimageName=…on the command line to override per-environment. -
BP_JVM_VERSIONis read by Paketo’s Liberica buildpack and pins the runtime JDK in the produced image. Match it tocompileJava.options.releaseso build-time and run-time bytecode levels agree. -
BPE_DELIM_JAVA_TOOL_OPTIONS+BPE_APPEND_JAVA_TOOL_OPTIONStogether append-XX:MaxRAMPercentage=75.0 -XX:+UseContainerSupportto whateverJAVA_TOOL_OPTIONSthe buildpack-managed memory calculator already sets. The two flags make the JVM (a) honour the cgroup memory limit set by the container runtime, (b) right-size its heap as 75% of that limit. We will look at why those numbers in the next-but-one chapter.
|
Paketo also exposes |
The Spring Boot Gradle plugin pinned in grails-core 8.0.x is spring-boot-gradle-plugin:4.0.5 (see git show 8.0.x:dependencies.gradle for the exact coordinate); bootBuildImage is part of that plugin and is enabled automatically by the org.apache.grails.gradle.web plugin the web profile applies.
Files touched in this chapter:
-
build.gradle
4.1 Run the Image and Verify
Build and run the image:
./gradlew bootBuildImage
docker run --rm -p 8080:8080 \
-e SPRING_PROFILES_ACTIVE=development \
ghcr.io/grails-guides/grails-docker-bootbuildimage:0.1
The first build downloads the Paketo builder image (~600 MB) and the buildpack base layers (~150 MB JRE, ~60 MB OS) into your local Docker daemon. Subsequent builds reuse them; the per-build delta after that is just your changed classes and resources, typically under 5 MB.
When the container is up, hit it from another terminal:
curl -i http://localhost:8080/
curl -s http://localhost:8080/actuator/health/readiness
The first request renders the Grails welcome page. The second returns {"status":"UP"} from the Spring Boot Actuator readiness probe we will configure in a later chapter.
|
|
Files touched in this chapter:
-
None
5 The Hand-Rolled Multi-Stage Dockerfile
For teams that need explicit control over every layer, the equivalent multi-stage Dockerfile lives at the project root:
# syntax=docker/dockerfile:1.7
#
# Hand-rolled multi-stage Dockerfile (alternative to bootBuildImage).
# Build with: docker build -t grails-docker-bootbuildimage:dockerfile .
# Run with: docker run --rm -p 8080:8080 grails-docker-bootbuildimage:dockerfile
#
# Use ./gradlew bootBuildImage instead unless you have a specific
# reason to control every layer (regulated builds, custom base image,
# air-gapped builds). See guide chapter `dockerfile.adoc`.
# ----------------------------------------------------------------------
# Build stage: compile + bootJar
# ----------------------------------------------------------------------
FROM bellsoft/liberica-openjdk-debian:21 AS build
WORKDIR /workspace
COPY gradlew gradlew.bat /workspace/
COPY gradle /workspace/gradle/
COPY build.gradle settings.gradle gradle.properties /workspace/
COPY grails-app /workspace/grails-app/
COPY src /workspace/src/
RUN --mount=type=cache,target=/root/.gradle \
./gradlew --no-daemon bootJar -x test
# ----------------------------------------------------------------------
# Runtime stage: minimal JRE + the bootJar
# ----------------------------------------------------------------------
FROM bellsoft/liberica-openjre-debian:21 AS runtime
# Run as a non-root user so a container break-out cannot become a host
# escalation. UID 10001 has no /etc/passwd entry by design.
RUN groupadd --system --gid 10001 grails \
&& useradd --system --uid 10001 --gid grails --no-create-home --shell /sbin/nologin grails
WORKDIR /app
COPY --from=build --chown=grails:grails /workspace/build/libs/*.jar /app/app.jar
USER 10001:10001
EXPOSE 8080
ENV JAVA_TOOL_OPTIONS='-XX:MaxRAMPercentage=75.0 -XX:+UseContainerSupport'
HEALTHCHECK --interval=10s --timeout=5s --start-period=30s --retries=12 \
CMD wget -qO- http://localhost:8080/actuator/health/readiness | grep -q UP || exit 1
ENTRYPOINT ["java", "-jar", "/app/app.jar"]
A few patterns are worth pointing at:
-
The build stage uses
bellsoft/liberica-openjdk-debian:21(a Debian-based JDK) and the runtime stage usesbellsoft/liberica-openjre-debian:21(the matching JRE). Splitting the two halves the runtime image and matches what the Paketo buildpack does internally. -
The
--mount=type=cache,target=/root/.gradleline tells BuildKit to keep the Gradle cache between builds. Without it every build re-downloads every dependency. -
groupadd+useraddcreate UID10001with no shell and no home directory.USER 10001:10001then runs the app as that UID. A container break-out is now an unprivileged process on the host. -
HEALTHCHECKcalls the same Actuator readiness endpoint the Compose file does. Docker reports the result viadocker ps’s `STATUScolumn.
|
The matching dockerignore
|
Files touched in this chapter:
-
Dockerfile -
.dockerignore
6 Environment-Driven application.yml
A production image cannot ship with a hard-coded jdbc:postgresql://localhost:5432/… URL; the same image has to run on a developer laptop, in CI, in staging, and in production with different connection strings, credentials, and even different database servers.
Grails uses the standard Spring Boot environment-variable → property mapping. Replace the literal values in grails-app/conf/application.yml with ${SPRING_DATASOURCE_URL:fallback} placeholders:
dataSource:
driverClassName: org.postgresql.Driver
username: '${SPRING_DATASOURCE_USERNAME:postgres}'
password: '${SPRING_DATASOURCE_PASSWORD:}'
pooled: true
jmxExport: true
environments:
development:
dataSource:
dbCreate: create-drop
url: '${SPRING_DATASOURCE_URL:jdbc:postgresql://localhost:5432/devDb?tcpKeepAlive=true}'
test:
dataSource:
dbCreate: update
url: 'jdbc:postgresql://localhost:5432/testDb?tcpKeepAlive=true'
production:
dataSource:
dbCreate: none
url: '${SPRING_DATASOURCE_URL}'
Three things to notice:
-
The
developmentblock keeps a fallback (jdbc:postgresql://localhost:5432/devDb…) so./gradlew bootRunstill works without aSPRING_DATASOURCE_URLset. -
The
productionblock has no fallback. If the image is started inproductionmode withoutSPRING_DATASOURCE_URLset, Spring Boot fails fast at startup. That is what you want. -
The username and password resolve from
SPRING_DATASOURCE_USERNAMEandSPRING_DATASOURCE_PASSWORDregardless of profile, with the username defaulting topostgresfor local development.
The Compose file we will write later passes these three variables to the container. Kubernetes deployments would pass the same variables via a Secret and envFrom: { secretRef: … }.
Files touched in this chapter:
-
grails-app/conf/application.yml
7 Container-Friendly JVM Flags
Two JVM flags need to be set for any JVM workload running inside a container:
-
-XX:+UseContainerSupport(default since JDK 11) tells the JVM to honour cgroup memory and CPU limits set by the container runtime, instead of inheriting the limits of the host kernel. Without it, a 256 MB container on a 64 GB host will see 64 GB of available memory, default to a heap sized for a 64 GB host, and OOM-kill on first GC. -
-XX:MaxRAMPercentage=75.0overrides the legacy-Xmxcalculation. The JVM setsXmxto 75% of the cgroup memory limit, leaving the rest for non-heap memory (metaspace, code cache, direct buffers, the JVM itself, and a small safety margin). 75 is conservative for a Grails app; tune to your profiler.
You set them once in build.gradle:
environment = [
'BP_JVM_VERSION' : '21',
'BPE_DELIM_JAVA_TOOL_OPTIONS' : ' ',
'BPE_APPEND_JAVA_TOOL_OPTIONS' : '-XX:MaxRAMPercentage=75.0 -XX:+UseContainerSupport'
]
The BPE_APPEND variant adds to whatever the buildpack-supplied JAVA_TOOL_OPTIONS already contains. The buildpack ships its own memory calculator output (heap, direct memory, metaspace, reserved code cache, thread stacks); appending preserves all of that and just bolts our two flags on the end.
If you switch to the hand-rolled Dockerfile you set the same two flags via ENV JAVA_TOOL_OPTIONS=… instead.
Files touched in this chapter:
-
build.gradle(thebootBuildImageblock) -
Dockerfile(only if you take the alternative path)
8 Spring Boot Actuator Health Probes
Spring Boot Actuator (already on the classpath in the starter) exposes liveness and readiness probes at /actuator/health/liveness and /actuator/health/readiness. Enable them with a small block in application.yml:
management:
endpoints:
web:
exposure:
include: health,info
endpoint:
health:
probes:
enabled: true
show-details: when-authorized
health:
livenessstate:
enabled: true
readinessstate:
enabled: true
The endpoints.web.exposure.include line restricts the public surface to just health and info; the operator-grade endpoints (metrics, env, threaddump, loggers) stay closed. endpoint.health.show-details: when-authorized means a curl against /actuator/health returns {"status":"UP"} to the world but the full per-component breakdown only to authenticated callers.
The health.livenessstate and health.readinessstate flags expose the two probes Kubernetes-style platforms expect:
-
/actuator/health/livenessanswers "should the platform restart this pod?". ReturnsDOWNonly on hard, unrecoverable JVM state. Use as a KuberneteslivenessProbe. -
/actuator/health/readinessanswers "should the platform send traffic to this pod?". ReturnsDOWNwhile the application is starting up, or while the database is unreachable. Use as a KubernetesreadinessProbeand as the DockerHEALTHCHECK.
For Docker Compose, the HEALTHCHECK lives on the service definition (next chapter). For Kubernetes, you would use httpGet: { path: /actuator/health/readiness, port: 8080 } instead.
Files touched in this chapter:
-
grails-app/conf/application.yml
9 Orchestrating the Stack with Docker Compose
A compose.yml at the project root orchestrates the application image plus a real PostgreSQL service:
services:
postgres:
image: postgres:16-alpine
environment:
POSTGRES_DB: appdb
POSTGRES_USER: appuser
POSTGRES_PASSWORD: appsecret
healthcheck:
test: ['CMD-SHELL', 'pg_isready -U appuser -d appdb']
interval: 5s
timeout: 5s
retries: 10
volumes:
- postgres-data:/var/lib/postgresql/data
app:
image: ghcr.io/grails-guides/grails-docker-bootbuildimage:latest
depends_on:
postgres:
condition: service_healthy
environment:
SPRING_PROFILES_ACTIVE: production
SPRING_DATASOURCE_URL: jdbc:postgresql://postgres:5432/appdb?tcpKeepAlive=true
SPRING_DATASOURCE_USERNAME: appuser
SPRING_DATASOURCE_PASSWORD: appsecret
JAVA_TOOL_OPTIONS: '-XX:MaxRAMPercentage=75.0 -XX:+UseContainerSupport'
ports:
- '8080:8080'
healthcheck:
test: ['CMD-SHELL', 'wget -qO- http://localhost:8080/actuator/health/readiness | grep -q UP || exit 1']
interval: 10s
timeout: 5s
retries: 12
start_period: 30s
volumes:
postgres-data:
The shape is two services and one named volume:
-
postgresrunspostgres:16-alpine. Thehealthcheckcallspg_isreadyevery five seconds; the database is considered healthy as soon as it accepts connections. Thevolumesblock persists data acrossdocker compose downso acompose upreset does not wipe your dev data; usedocker compose down -vto reset. -
appruns the image we built withbootBuildImage. Thedepends_onblock usescondition: service_healthy, which means Compose will not start the app container until the Postgres healthcheck has flipped to healthy. Without this, the app would start, fail to connect to a still-booting Postgres, and crash on first attempt. -
The five environment variables (
SPRING_PROFILES_ACTIVE, the three datasource ones,JAVA_TOOL_OPTIONS) are exactly the ones ourapplication.ymland JVM-tuning chapters established.JAVA_TOOL_OPTIONSis also set in the image, but having it in the Compose file makes the runtime flags greppable from outside the image. -
The app’s
healthcheckcallswget(which the Paketo image happens to have) against/actuator/health/readiness. Compose surfaces the result viadocker compose ps.
Bring it all up with:
docker compose up
The first run pulls the Postgres image, creates the volume, starts Postgres, waits for healthy, then starts the app. Subsequent runs skip the pull.
Files touched in this chapter:
-
compose.yml
9.1 Wiring the App to a Real Postgres
The Compose file’s app service receives three datasource variables:
SPRING_DATASOURCE_URL: jdbc:postgresql://postgres:5432/appdb?tcpKeepAlive=true
SPRING_DATASOURCE_USERNAME: appuser
SPRING_DATASOURCE_PASSWORD: appsecret
Two things to highlight:
-
The host is
postgres, notlocalhostor127.0.0.1. Compose creates a default bridge network and registers each service name as a DNS A record on it; the application resolvespostgresto the database container’s IP without any extra wiring. -
The credentials match the
POSTGRES_DB,POSTGRES_USER, andPOSTGRES_PASSWORDset on thepostgresservice. In a real deployment you would split these out into a.envfile or a Compose secrets block; for the sample app we keep them inline so the moving parts are visible.
The application.yml we wrote in the env-profiles chapter resolves these three variables transparently. Spring Boot’s relaxed binding maps SPRING_DATASOURCE_URL to the property spring.datasource.url, which Grails forwards into its own dataSource.url slot. No glue code in the app itself.
|
For functional tests, the |
Files touched in this chapter:
-
compose.yml
10 Pushing to GitHub Container Registry
GitHub Container Registry (GHCR) is the easiest place to host an image for a project that already lives on GitHub: it inherits the repository’s visibility and access policies, supports anonymous pulls for public repos, and is free for public images.
Push the locally built image:
echo "$GITHUB_TOKEN" | docker login ghcr.io -u <your-github-username> --password-stdin
docker push ghcr.io/grails-guides/grails-docker-bootbuildimage:0.1
$GITHUB_TOKEN is a personal access token with the write:packages scope. From a GitHub Actions workflow it is the auto-provisioned ${{ secrets.GITHUB_TOKEN }}; from a developer laptop it is a token you mint at github.com/settings/tokens.
The first time an image is pushed under a given repo path, GHCR creates the package and links it to the same GitHub repo. Make it public via the GHCR UI (Package settings → Change visibility → Public) so anonymous pulls work without authentication.
|
The matching CI/CD guide automates this whole flow on every git tag: a |
Files touched in this chapter:
-
None (push happens from the developer shell or from CI)
11 Running as a Non-Root User
Containers should not run as root, full stop. A process running as root inside a container is root on the host kernel for the duration of any successful container break-out; even when the container runtime drops most capabilities, defence in depth says don’t start with the maximum privilege.
Both image paths in this guide give you a non-root runtime user for free:
-
The
bootBuildImagetask uses Paketo’s CNB-runtime base image, which already runs the application as usercnb(UID1000). You can confirm by addingentrypoint = ['id']to adocker runinvocation and inspecting the output. -
The hand-rolled
Dockerfilecreates UID10001with no shell, no home directory, and no/etc/passwdentry, thenUSER 10001:10001switches to it. The chosen UID is high enough to be unambiguously non-system; some CIS benchmarks recommend>= 10000for application containers.
In Kubernetes, you reinforce this at the pod level with a securityContext:
securityContext:
runAsNonRoot: true
runAsUser: 10001
allowPrivilegeEscalation: false
readOnlyRootFilesystem: true
capabilities:
drop: [ ALL ]
readOnlyRootFilesystem: true is the strictest setting. The Paketo image already supports it; the hand-rolled Dockerfile may need a tmpfs volume mounted at /tmp for things like Tomcat’s work directory. The trade-off is yours to make.
Files touched in this chapter:
-
None directly (
Dockerfilealready creates the non-root user)
12 Do you need help with Grails?
Help with Apache Grails
Apache Grails is supported by an active community of contributors and the Apache Software Foundation. If you need help working through a guide, want to discuss the framework, or have run into something that looks like a bug, the channels below are the right place to start.
-
Slack - real-time conversation with the Apache Grails community.
-
Developer mailing list - design discussions and contributor coordination.
-
Users mailing list - end-user questions and answers.
-
Issue tracker on GitHub - file a bug or feature request against the framework.
For Grails plugins, see the matching project on the apache org or the plugin’s own GitHub repository.