Show Navigation

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 bootBuildImage task wired up so a single command produces a minimal OCI image tagged ghcr.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.yml that resolves its datasource URL, username, and password from environment variables, with sensible development defaults so ./gradlew bootRun still works against a local Postgres.

  • A compose.yml stack that runs the image alongside postgres:16-alpine, with a Spring Boot Actuator-driven HEALTHCHECK and a depends_on: condition: service_healthy block so the app waits for the database to be ready before it starts.

  • An equivalent multi-stage Dockerfile (build stage on bellsoft/liberica-openjdk-debian:21, runtime stage on the matching JRE image) that runs the app as UID 10001 and 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 buildx and docker compose are 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 the postgres, testcontainers, and database-migration features 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 in grails-app/conf/application.yml

  • testImplementation "org.testcontainers:postgresql" (and org.testcontainers:spock + :testcontainers)

  • implementation "org.apache.grails:grails-data-hibernate5-dbmigration"

The forge’s default build.gradle ships compileJava.options.release = 17. The complete/ tree in the upstream repo patches it to 21; do the same in your local checkout.

3 Two Paths to a Container Image

Two paths produce a runnable container image from the same bootJar:

  • Spring Boot 4’s bootBuildImage task 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:

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:

  • imageName accepts a fully-qualified registry path. We default to ghcr.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_VERSION is read by Paketo’s Liberica buildpack and pins the runtime JDK in the produced image. Match it to compileJava.options.release so build-time and run-time bytecode levels agree.

  • BPE_DELIM_JAVA_TOOL_OPTIONS + BPE_APPEND_JAVA_TOOL_OPTIONS together append -XX:MaxRAMPercentage=75.0 -XX:+UseContainerSupport to whatever JAVA_TOOL_OPTIONS the 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 BPL_JVM_HEAD_ROOM, BPL_JVM_LOADED_CLASS_COUNT, and BPL_JVM_THREAD_COUNT as runtime tuning knobs. The defaults are reasonable for a typical Grails app; reach for them only when JVM memory profiles drive the conversation.

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.

docker history ghcr.io/grails-guides/grails-docker-bootbuildimage:0.1 shows you the layer breakdown the buildpack produced. Notice that classes/resources sit on top of dependencies; a code-only change rebuilds only that one layer.

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:

Dockerfile
# 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 uses bellsoft/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/.gradle line tells BuildKit to keep the Gradle cache between builds. Without it every build re-downloads every dependency.

  • groupadd + useradd create UID 10001 with no shell and no home directory. USER 10001:10001 then runs the app as that UID. A container break-out is now an unprivileged process on the host.

  • HEALTHCHECK calls the same Actuator readiness endpoint the Compose file does. Docker reports the result via docker ps’s `STATUS column.

The matching .dockerignore file excludes .gradle/, build/, out/, and other ephemera so the build context stays small:

dockerignore
.gradle/
build/
out/
.idea/
*.iml
.git/
.github/
*.log
.DS_Store
Thumbs.db
README.md
compose.yml

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:

grails-app/conf/application.yml
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 development block keeps a fallback (jdbc:postgresql://localhost:5432/devDb…​) so ./gradlew bootRun still works without a SPRING_DATASOURCE_URL set.

  • The production block has no fallback. If the image is started in production mode without SPRING_DATASOURCE_URL set, Spring Boot fails fast at startup. That is what you want.

  • The username and password resolve from SPRING_DATASOURCE_USERNAME and SPRING_DATASOURCE_PASSWORD regardless of profile, with the username defaulting to postgres for 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.0 overrides the legacy -Xmx calculation. The JVM sets Xmx to 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 (the bootBuildImage block)

  • 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:

grails-app/conf/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/liveness answers "should the platform restart this pod?". Returns DOWN only on hard, unrecoverable JVM state. Use as a Kubernetes livenessProbe.

  • /actuator/health/readiness answers "should the platform send traffic to this pod?". Returns DOWN while the application is starting up, or while the database is unreachable. Use as a Kubernetes readinessProbe and as the Docker HEALTHCHECK.

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:

compose.yml
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:

  • postgres runs postgres:16-alpine. The healthcheck calls pg_isready every five seconds; the database is considered healthy as soon as it accepts connections. The volumes block persists data across docker compose down so a compose up reset does not wipe your dev data; use docker compose down -v to reset.

  • app runs the image we built with bootBuildImage. The depends_on block uses condition: 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 our application.yml and JVM-tuning chapters established. JAVA_TOOL_OPTIONS is also set in the image, but having it in the Compose file makes the runtime flags greppable from outside the image.

  • The app’s healthcheck calls wget (which the Paketo image happens to have) against /actuator/health/readiness. Compose surfaces the result via docker 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, not localhost or 127.0.0.1. Compose creates a default bridge network and registers each service name as a DNS A record on it; the application resolves postgres to the database container’s IP without any extra wiring.

  • The credentials match the POSTGRES_DB, POSTGRES_USER, and POSTGRES_PASSWORD set on the postgres service. In a real deployment you would split these out into a .env file 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 testcontainers feature means you do not need a long-running Postgres at all - Spock specs can spin one up per spec via Testcontainers' Postgres module, run their assertions, and tear it down. The CI/CD guide shows how that integrates with GitHub Actions.

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 release.yml workflow runs bootBuildImage against the tag’s source, pushes to GHCR with the tag name as the image tag, and creates a GitHub Release with the bootJar attached. See the GitHub Actions CI/CD with Grails 8 guide for the workflow file.

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 bootBuildImage task uses Paketo’s CNB-runtime base image, which already runs the application as user cnb (UID 1000). You can confirm by adding entrypoint = ['id'] to a docker run invocation and inspecting the output.

  • The hand-rolled Dockerfile creates UID 10001 with no shell, no home directory, and no /etc/passwd entry, then USER 10001:10001 switches to it. The chosen UID is high enough to be unambiguously non-system; some CIS benchmarks recommend >= 10000 for 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 (Dockerfile already 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.

For Grails plugins, see the matching project on the apache org or the plugin’s own GitHub repository.