(Quick Reference)

26 Deployment

Version: 8.0.0

26 Deployment

Grails applications can run as executable archives, in an external servlet container, or in an OCI container image. Choose the packaging that fits your deployment platform:

Deployment Build artifact When to use it

Standalone

Executable JAR (bootJar) or WAR (bootWar)

A VM or host where a service manager runs Java and supervises the application.

External servlet container

Deployable WAR (bootWar)

An existing Jakarta Servlet container, such as Tomcat, manages the application lifecycle.

OCI container image

An image built with Docker, Buildah, or Spring Boot’s bootBuildImage

A container runtime or orchestrator manages application instances. The embedded servlet container runs inside the image.

The examples in this chapter target Grails 8, Spring Boot 4.1, and Java 21 or later. Use the Gradle Wrapper and dependency versions supplied by your Grails application. The Java runtime must support the bytecode produced by your build and all its dependencies; test with the Java major version used in production. Applications using Micronaut require Java 25 or later. Run the commands from your application’s root directory, not the Grails Framework source repository.

Build and test a release artifact once, then promote that same artifact or image digest through your environments. Supply environment-specific configuration at runtime, and retain the previous artifact for rollback. The production configuration section covers external configuration, secrets, graceful shutdown, health probes, and HTTPS for these deployment options. For startup optimizations, see ahead-of-time processing and ahead-of-time caching.

26.1 Standalone

A standalone application runs with an embedded servlet container. The deployment host needs a compatible Java runtime, but does not need Grails or Gradle installed.

Build and run an executable archive

Build an executable JAR with the application’s Gradle Wrapper:

./gradlew clean check bootJar
java -Dgrails.env=prod -jar build/libs/myapp-0.1.jar

Replace myapp-0.1.jar with the actual bootJar output. A plain JAR (usually named -plain.jar) does not contain the embedded server and dependencies required by java -jar. Use an exact filename in deployment scripts; build/libs/.jar can match plain archives or artifacts from earlier builds. check runs the verification tasks configured by your application; include any separately configured integration or functional test jobs in your release pipeline as well.

Grails packaging defaults to the production environment unless it is overridden at build time. The explicit -Dgrails.env=prod above also selects production at runtime. JVM options, including -D properties and memory settings, go before -jar; Spring Boot application arguments go after the archive name:

java -Xmx768m -Dgrails.env=prod -jar build/libs/myapp-0.1.jar \
    --server.port=8081 \
    --spring.config.additional-location=file:/etc/myapp/

This example requires /etc/myapp/ to exist. Place external application.yml or application.properties there. Size the heap for your workload and leave memory for metaspace, thread stacks, direct buffers, native libraries, and the operating system.

If the application applies the Gradle war plugin, it can instead produce an executable WAR:

./gradlew clean check bootWar
java -Dgrails.env=prod -jar build/libs/myapp-0.1.war

See external servlet deployment for configuring the embedded container dependencies so the same WAR can also be deployed to Tomcat.

Run as a managed service

Use a service manager such as systemd to start the JVM on boot, restart failed processes, and collect logs. For example, after creating an unprivileged myapp account and installing the tested JAR at /opt/myapp/application.jar, a Linux service unit can contain:

[Unit]
Description=My Grails application
After=network.target

[Service]
User=myapp
Group=myapp
WorkingDirectory=/opt/myapp
ExecStart=/usr/bin/java -Dgrails.env=prod -jar /opt/myapp/application.jar --spring.config.additional-location=file:/etc/myapp/
Restart=on-failure
RestartSec=5s
TimeoutStopSec=60s
SuccessExitStatus=143
NoNewPrivileges=true
UMask=0027

[Install]
WantedBy=multi-user.target

Adjust the Java path and stop timeout for your host and application. The service account needs read access to the archive and configuration, and write access only to required temporary or application data directories. Keep the JVM in the foreground so the service manager receives its exit status and can send SIGTERM for graceful shutdown.

Development commands

./gradlew bootRun and grails run-app run an application from its development build. They are useful for local work, including checking production configuration with ./gradlew -Dgrails.env=prod bootRun. For a production service, deploy and smoke-test the packaged archive rather than keeping a Gradle process and source checkout on the host. For container images built from that archive, see OCI images and layered builds.

26.2 External Servlet Container Deployment

An external servlet container manages one or more applications deployed as WAR files. This differs from an OCI container image, which normally runs an executable Grails JAR with its embedded server.

Prepare a deployable WAR

Ensure the application applies Gradle’s war plugin. Add it to the existing plugins block, or use apply plugin: 'war' if the build uses imperative plugin application. The Grails web application plugins configure Spring Boot’s packaging tasks; an additional Spring Boot plugin version is not required.

For a Tomcat application, keep the Tomcat starter as an application dependency and declare its runtime components using providedRuntime in build.gradle:

dependencies {
    implementation 'org.springframework.boot:spring-boot-starter-tomcat'
    providedRuntime('org.springframework.boot:spring-boot-starter-tomcat-runtime') {
        // Grails beans also use Spring Boot's integration classes in an external container.
        exclude group: 'org.springframework.boot'
    }
}

The -runtime starter is Spring Boot 4’s way to identify the runtime components that must be provided by an external servlet container. It includes the embedded Tomcat libraries. bootWar packages those components in WEB-INF/lib-provided, outside the external servlet container’s application library path, while keeping them available to executable WAR launches. The exclusion applies only to this provided dependency: Spring Boot’s integration modules remain ordinary application dependencies through spring-boot-starter-tomcat and are packaged in WEB-INF/lib. Grails uses their types when registering application beans, and an external Tomcat installation does not supply them. providedRuntime also keeps them on the test classpath; testImplementation would make them unavailable when running the packaged WAR with java -jar. Review any additional servlet-container dependencies for the same packaging requirements.

The Grails Gradle plugin applies the selected Grails BOM to providedRuntime by default, so these dependencies do not need explicit versions. An application that opts out of automatic BOM management must supply its chosen platform to this configuration as well as its ordinary application configurations.

Grails generates servlet initialization support for the application’s conventional Application class. Retain the generated Grails application structure rather than replacing it with a separate Spring Boot application entry point.

./gradlew clean check bootWar

Deploy the resulting executable WAR from build/libs, for example myapp-0.1.war, using the servlet container’s deployment mechanism. Use the bootWar output, not a *-plain.war. The grails war command is also available.

Container requirements and configuration

Grails 8 requires a Jakarta Servlet 6.1-compatible container, such as Tomcat 11, and Java 21 or later. Older Tomcat generations, including Tomcat 10.1 (Servlet 6.0), do not meet this requirement. Test the packaged WAR against the actual container and Java versions used in production, including any application-server-specific classloading configuration.

For external deployments:

  • Configure listeners, TLS certificates, connection limits, and graceful undeployment in the servlet container. Spring Boot’s embedded-server settings do not configure an external server’s connectors.

  • Configure the context path in the servlet container. For example, Tomcat normally derives it from the WAR name; ROOT.war deploys at /. An embedded-server server.servlet.context-path setting does not replace this configuration.

  • Supply external configuration and secrets through the deployment platform. JVM system properties apply to every application in a shared server JVM, so do not use them for settings that must differ between deployed applications.

  • Check startup logs, health, a representative application request, and static resources after deployment. Exercise shutdown and redeployment as part of release testing.

See the Spring Boot traditional deployment guide for further WAR packaging and application server guidance.

26.3 OCI Images and Layered Builds

An OCI image packages the application and a Java runtime for Docker, Podman, Kubernetes, or another compatible platform. Use a Dockerfile when you need control over the base image and installed software, or Spring Boot’s bootBuildImage task when you want Cloud Native Buildpacks to manage that packaging. Buildah can build the same Dockerfile on Linux without a Docker daemon.

Prepare the application artifact

Build and verify the application before creating the image. This preserves the complete Grails build, including grails-app, compiled views, assets, resources, and dependencies from other modules. The image build below consumes the resulting executable JAR; it does not run Gradle inside the image.

In the existing application’s build.gradle, give bootJar a predictable filename and reproducible archive entries readable by a non-root runtime:

tasks.named('bootJar') {
    archiveFileName = 'application.jar'
    preserveFileTimestamps = false
    reproducibleFileOrder = true
    filePermissions {
        unix('0644')
    }
    dirPermissions {
        unix('0755')
    }
}

Stable ordering and timestamps help identical content reuse image layers across build hosts. Explicit read permissions also avoid importing owner-only permissions from a build agent’s dependency cache into a buildpack image. If your archive includes native executables, give those specific entries execute permission as well.

Grails applies Spring Boot’s Gradle plugin. In Spring Boot 4.1, layering and the JAR tools are enabled by default; no separate layering plugin or forced plugin dependency is needed. If the application has disabled bootJar, layering, or includeTools, enable them again for this workflow. Do not configure launchScript() for this JAR: extraction with jarmode=tools does not support archives with a prepended shell launch script.

./gradlew clean check bootJar
java -Djarmode=tools -jar build/libs/application.jar list-layers

The default layers, in order, are:

  1. dependencies: non-project dependencies whose versions do not contain SNAPSHOT.

  2. spring-boot-loader: Spring Boot loader content.

  3. snapshot-dependencies: non-project dependencies whose versions contain SNAPSHOT.

  4. application: application classes, resources, and dependencies on other projects in the build.

Resources belong to application; there is no separate default resources layer. The layer index in a JAR is metadata. Copying the entire JAR in a single Dockerfile instruction does not create separately reusable image layers. Extract the layers and copy them individually, from least frequently changed to most frequently changed. This reduces repeated registry transfers and storage across image versions that share identical layers. It does not remove dependencies or necessarily make one image smaller than a single-JAR image.

Build a layered image with Docker

Create Dockerfile in the application root:

ARG JAVA_IMAGE=docker.io/library/eclipse-temurin:21-jre-noble

FROM ${JAVA_IMAGE} AS extract
WORKDIR /builder
COPY build/libs/application.jar application.jar
RUN java -Djarmode=tools -jar application.jar extract --layers --destination extracted

FROM ${JAVA_IMAGE} AS runtime
WORKDIR /application
RUN groupadd --gid 10001 app \
    && useradd --uid 10001 --gid app --no-create-home --no-log-init --home-dir /application app
COPY --from=extract /builder/extracted/dependencies/ ./
COPY --from=extract /builder/extracted/spring-boot-loader/ ./
COPY --from=extract /builder/extracted/snapshot-dependencies/ ./
COPY --from=extract /builder/extracted/application/ ./
USER 10001:10001
EXPOSE 8080
ENTRYPOINT ["java", "-Dgrails.env=prod", "-jar", "application.jar"]

The runtime application.jar is the small archive produced by compact extraction, containing the application classes and resources, including compiled assets. Its manifest references the extracted dependencies beside it. Keep all extracted layers together in /application, and run this archive with java -jar as shown above. The original large JAR stays in the extraction stage. This layout does not require a hard-coded Spring Boot launcher class. The application runs as a non-root user, with root-owned, read-only application files. Provide separate writable mounts if it stores data.

The base image tag is a readable example. For release builds, resolve and record a tested base-image digest and pass its full name@sha256:…​ reference as the JAVA_IMAGE build argument. Update that digest regularly to take in runtime and OS fixes. Use a compatible Java version in both stages; select a JDK instead of a JRE only when runtime diagnostics or application requirements need it. Changing the distribution also requires adapting groupadd and useradd. Check native libraries, fonts, locale data, and CA certificates before choosing a smaller base; Alpine’s musl libc is not a drop-in replacement for glibc for every dependency.

Limit the build context with a .dockerignore beside the Dockerfile:

**
!Dockerfile
!build/
!build/libs/
!build/libs/application.jar

Only the packaged application is needed. This also prevents source checkouts, local Gradle caches, and external credentials from being sent as build context. It cannot remove secrets already packaged into the JAR: keep those in runtime configuration. For a multi-project application, run the subproject’s bootJar task and adjust the JAR path and ignore rules, or stage that task’s exact output in a dedicated build context.

docker build --pull --tag myapp:0.1 .
docker run --rm --name myapp \
    --publish 127.0.0.1:8080:8080 \
    --memory=1g --cpus=2 \
    --read-only --tmpfs /tmp:rw,nosuid,nodev,size=128m \
    --cap-drop=ALL --security-opt=no-new-privileges \
    --env JAVA_TOOL_OPTIONS='-XX:MaxRAMPercentage=60.0 -XX:+ExitOnOutOfMemoryError' \
    myapp:0.1

Supply the database and other required runtime configuration before starting your application. The limits above are examples to load-test, not universal Grails defaults. The writable /tmp supports the embedded server and temporary files; add appropriately permissioned volumes for uploads or other required writes. EXPOSE documents the port but does not publish it. This example binds it to host loopback for local testing; production traffic should enter through your platform’s service or ingress. See production configuration for memory sizing, probes, logging, and shutdown.

Build with rootless Buildah

Install Buildah on a Linux build host with user namespaces, subordinate UID/GID mappings, and rootless container storage configured for the build account. Run the following as that account after building the JAR. On macOS or Windows, use a Linux VM or a Linux CI runner for Buildah.

buildah build --layers --pull=always \
    --file Dockerfile --tag localhost/myapp:0.1 .
podman run --rm --name myapp \
    --publish 127.0.0.1:8080:8080 \
    --memory=1g --cpus=2 \
    --read-only --tmpfs /tmp:rw,nosuid,nodev,size=128m \
    --cap-drop=ALL --security-opt=no-new-privileges \
    --env JAVA_TOOL_OPTIONS='-XX:MaxRAMPercentage=60.0 -XX:+ExitOnOutOfMemoryError' \
    localhost/myapp:0.1

Buildah reads .dockerignore when no .containerignore is present; if both exist, it uses .containerignore. Keep their rules consistent. --layers enables caching of intermediate builds. Keep the separate layer COPY instructions; squashing the image defeats reuse of those layers. Rootless building and a non-root runtime USER are separate properties: this example provides both. Podman can use Buildah’s local image storage when run as the same user with the same storage configuration. Docker uses separate storage, so transfer the image through a registry when switching runtimes. Resource controls for rootless containers also depend on the host’s cgroup configuration.

To publish a tested image, authenticate to your registry, then push it. Replace registry.example.com/team with your own registry namespace:

buildah login registry.example.com
buildah push --digestfile build/image-digest.txt \
    localhost/myapp:0.1 docker://registry.example.com/team/myapp:0.1

In CI, supply authentication with a protected auth file or buildah login --password-stdin. Do not put credentials in build arguments or image layers. The digest file contains the pushed manifest digest; deploy registry.example.com/team/myapp@sha256:…​ using that value. For ephemeral runners, Buildah can store its build cache in a registry:

buildah build --layers --pull=always \
    --cache-from registry.example.com/team/myapp-build-cache \
    --cache-to registry.example.com/team/myapp-build-cache \
    --file Dockerfile --tag localhost/myapp:0.1 .

Authenticate before using a private cache, restrict its writers, and configure retention. Buildah’s registry-cache options differ from Docker BuildKit’s cache options. See the Buildah build reference for host requirements and available flags.

Cloud Native Buildpacks

Spring Boot’s bootBuildImage builds an OCI image from the application’s executable archive without a Dockerfile. It uses a builder image and a compatible run image to supply the Java runtime and application layers, and runs the resulting application as a non-root user. It requires a Docker-compatible daemon; a configured Podman API connection is also supported. It does not use Buildah or the Dockerfile above.

Select the executable JAR explicitly, and for Paketo Java buildpacks configure the Java major version to match the application:

tasks.named('bootBuildImage') {
    archiveFile = tasks.named('bootJar').flatMap { it.archiveFile }
    environment.put('BP_JVM_VERSION', '21')
}

Explicitly selecting bootJar matters when the war plugin is also applied: Spring Boot otherwise uses bootWar for image creation, which may select a different buildpack deployment layout.

./gradlew clean check bootBuildImage --imageName=myapp:0.1

Use the builder supported by the Spring Boot plugin version in your application, or select a maintained builder and its compatible run image explicitly. For repeatable release builds, pin both images to tested digests and schedule rebuilds when they change. Check that the builder, run image, and buildpacks support your target CPU architecture and any required native libraries.

Paketo’s Java buildpack calculates JVM memory settings from the container memory limit and non-heap requirements. Set a container limit and tune its documented BPL_JVM_* runtime options for your workload rather than copying the manual Dockerfile’s heap percentage into a buildpack image. Keep the buildpack launcher as the entry point so its memory calculation, bindings, and runtime configuration execute. BP_* settings configure the image build; deployment-specific configuration and secrets belong at runtime. When image tags change on every release, use stable, application-specific buildCache and launchCache volume names if you want to retain buildpack caches across versions.

For a local smoke test, supply the application’s required external configuration and run the image with a memory limit:

docker run --rm --name myapp \
    --publish 127.0.0.1:8080:8080 --memory=1g --cpus=2 \
    --read-only --tmpfs /tmp:rw,nosuid,nodev,size=128m \
    --cap-drop=ALL --security-opt=no-new-privileges \
    --env JAVA_TOOL_OPTIONS='-Dgrails.env=prod' \
    myapp:0.1

Verify writable paths required by the selected buildpacks and any agents or bindings you enable. Here the buildpack retains control of heap sizing; the memory and temporary-storage limits still need workload-specific tuning.

See Spring Boot’s Gradle OCI image reference for daemon connections, cache configuration, registry authentication, and publishing, and Paketo’s Java reference for runtime tuning.

Release and architecture considerations

  • Tag images with a release version or commit identifier, record the source revision and artifact checksum in your build metadata, and promote the tested digest instead of rebuilding per environment.

  • Scan the final image, retain its software bill of materials, and rebuild for base-image and application-dependency fixes. A cached layer is reusable, not automatically up to date.

  • Build for the deployment platform explicitly when it differs from the build host, such as --platform=linux/amd64 on an ARM workstation. Both Docker and Buildah support selecting a platform, but the extraction stage executes Java and therefore needs a native runner or configured emulation for that platform.

  • A Java JAR may be portable while JNI libraries and bundled executables are architecture-specific. Build and smoke-test each target architecture. Publish a multi-platform image index only after its constituent images pass; a single-platform image tag is not a multi-platform release.

  • Verify a representative controller request, compiled views, static assets, database access, and termination using the final image and production-like resource limits. Check that an application-only change reuses dependency layers, for example with docker history and registry layer digests.

The Spring Boot Dockerfile guide explains JAR extraction, and the efficient container images guide describes layer ordering. For further startup optimizations, see ahead-of-time processing and ahead-of-time caching. A trained JVM cache must match the deployment’s JDK, application, architecture, and runtime layout. See also Docker’s image-building best practices.

26.4 Production Configuration

External configuration and secrets

Select the Grails production environment with -Dgrails.env=prod when starting Java. Grails environments and Spring profiles are distinct mechanisms; setting SPRING_PROFILES_ACTIVE=prod alone is not a substitute for selecting the Grails environment. Keep database credentials, signing keys, and certificates outside the release artifact and image.

For a standalone archive or container, Spring Boot can load an additional directory containing application.yml or application.properties:

java -Dgrails.env=prod -jar application.jar \
    --spring.config.additional-location=file:/etc/myapp/

spring.config.additional-location adds to the default search locations; spring.config.location replaces them. The trailing slash identifies a directory. This example fails at startup if the location is missing; add optional: only for configuration that really is optional. In the container examples, mount configuration read-only at /etc/myapp/ and set SPRING_CONFIG_ADDITIONAL_LOCATION=file:/etc/myapp/. Do not mount over /application, which contains the extracted application and its dependencies.

For Grails-specific Groovy configuration files, see external configuration and grails.config.locations. That mechanism skips missing locations, so it should not be relied on to enforce the presence of required secrets. Test the effective configuration in the packaged application, especially when combining Spring properties with Grails environment-specific blocks.

For example, a GORM application can refer to deployment-provided values explicitly in grails-app/conf/application.yml:

environments:
    production:
        dataSource:
            dbCreate: none
            url: ${JDBC_URL}
            username: ${JDBC_USERNAME}
            password: ${JDBC_PASSWORD}

Also configure the database driver and its runtime dependency for the chosen database. Supply the values through your platform’s secret management; do not commit a populated environment file or bake it into an image. Prefer mounted secret files where available. Spring Boot supports spring.config.import=configtree:/run/secrets/ to expose file names as property names and their contents as values, which application configuration can reference with placeholders. Use read-only mounts and permissions that allow the runtime user to read the required files. Environment variables and JVM options can appear in process inspection or diagnostic output; do not log their contents.

Apply versioned database migrations as a coordinated release step. Do not use create, create-drop, or automatic schema update as a production migration strategy. Plan schema changes to support both old and new application versions during a rolling deployment and rollback. Budget connection pools across all replicas, including temporarily overlapping replicas during rollout.

Memory, storage, and logging

For the manual Dockerfile, set a container memory limit and tune -Xmx or -XX:MaxRAMPercentage against that limit. Modern supported JVMs detect container limits by default; adding -XX:+UseContainerSupport is normally unnecessary. Heap is only part of total memory: allow space for metaspace, code cache, direct buffers, thread stacks, native libraries, monitoring agents, and memory-backed temporary files. A container can be killed for exceeding its limit even if the Java heap has not run out of memory. Measure resident memory and GC behavior under load rather than copying a fixed heap percentage between applications.

JAVA_TOOL_OPTIONS is read by the JVM and works with the exec-form entry point in the Dockerfile. JAVA_OPTS has no automatic meaning to java, and Docker does not expand variables inside an exec-form entry point. For a buildpack image, preserve the launcher and use the buildpack’s memory configuration, as described in Cloud Native Buildpacks.

Write application logs to standard output and standard error for the service manager or container platform to collect, with platform-managed retention. Keep uploads and durable data in external storage or persistent volumes; an image’s writable layer and /tmp are disposable. Configure writable paths explicitly when using a read-only root filesystem. Heap dumps and other diagnostics need a writable destination with sufficient space and controlled access. Keep JVM debugging and management interfaces private; they should not be exposed through the application’s public ingress.

Graceful shutdown and rolling updates

Spring Boot 4.1 enables graceful shutdown by default for supported embedded web servers. The following configuration makes the intent explicit and sets the timeout per shutdown phase:

server:
    shutdown: graceful
spring:
    lifecycle:
        timeout-per-shutdown-phase: 30s

The container entry point must forward termination signals to Java. The Dockerfile uses exec form so Java is PID 1 and receives SIGTERM directly. If a startup script is needed, finish it with exec java …​. If the application spawns child processes, consider the runtime’s init support (such as docker run --init) for signal forwarding and child reaping.

Allow the platform longer than the application’s shutdown work before it sends SIGKILL. For example, stop a locally running Docker container from another terminal with:

docker stop -t 60 myapp

The timeout is per Spring lifecycle phase, not a global upper bound. Allow for all phases, background jobs, and load-balancer draining when setting the service manager’s stop timeout or Kubernetes terminationGracePeriodSeconds. If using a Kubernetes preStop hook to allow endpoint removal to propagate, its execution also consumes the termination grace period. Test termination with in-flight requests through the actual ingress, including long-running or streaming requests.

Run scheduled jobs and message consumers with a defined shutdown and multi-replica strategy. Keep HTTP sessions outside the process when they must survive rescheduling, or explicitly design for their loss. Sticky routing alone does not preserve sessions when a replica disappears.

Health probes

Use separate startup, liveness, and readiness probes when deploying to an orchestrator. If the application does not already include Spring Boot Actuator, add its Grails-managed dependency:

dependencies {
    implementation 'org.springframework.boot:spring-boot-starter-actuator'
}

Merge these settings into application.yml:

management:
    endpoints:
        web:
            exposure:
                include: health
    endpoint:
        health:
            show-details: never
            probes:
                enabled: true
                add-additional-paths: true

Spring Boot 4.1 enables the liveness and readiness health groups by default. The configuration makes that explicit and additionally exposes /livez and /readyz on the main server port as well as the Actuator health-group paths. Probing the main port avoids a separately hosted management server reporting success while the application connector is unavailable. Allow the probe requests through the application’s security rules without a login redirect, and restrict probe access at the network or ingress layer as appropriate. Do not expose all Actuator endpoints to make a health check work.

For example, the following is a Kubernetes Pod-spec fragment (the spec.template.spec portion of a Deployment), assuming the default root context path and HTTP on port 8080:

terminationGracePeriodSeconds: 60
containers:
    - name: myapp
      image: registry.example.com/team/myapp:0.1
      ports:
          - name: http
            containerPort: 8080
      startupProbe:
          httpGet:
              path: /livez
              port: http
          periodSeconds: 5
          failureThreshold: 60
          timeoutSeconds: 2
      livenessProbe:
          httpGet:
              path: /livez
              port: http
          periodSeconds: 10
          timeoutSeconds: 2
      readinessProbe:
          httpGet:
              path: /readyz
              port: http
          periodSeconds: 5
          timeoutSeconds: 2

Replace the illustrative tag with the tested image digest for release deployment, and add resource requests/limits, configuration, secret mounts, and security context appropriate to the application. Adjust paths and schemes if using a context path or HTTPS. The startup probe allows up to five minutes here before restarts; tune that budget from measured startup times. Kubernetes defers liveness and readiness probes until the startup probe succeeds. A failing readiness probe removes the replica from service traffic; a failing liveness probe restarts it.

Do not make liveness depend on the database, a remote API, or another shared service: an outage can otherwise cause every application instance to restart. Spring Boot’s readiness group does not include external dependencies by default. Include only checks that should prevent that instance from receiving requests, and consider what happens if every replica becomes unready during a shared dependency outage. Validate that probes return the intended status rather than a security redirect or a generic application page. See the Spring Boot Kubernetes probes reference.

HTTPS and reverse proxies

Many deployments terminate TLS at a reverse proxy or ingress. Keep certificate renewal there and restrict direct access to the application port. Configure forwarded-header handling only for a trusted proxy path, and have the boundary proxy remove untrusted client-supplied Forwarded and X-Forwarded-* headers. Choose server.forward-headers-strategy=NATIVE for supported embedded-container handling or FRAMEWORK for Spring’s handling, according to your proxy’s headers and trust configuration. Test redirects, absolute URLs, secure cookies, and client-address handling through that proxy. See the Spring Boot proxy guidance.

To terminate TLS in the embedded server instead, merge settings such as these into application.yml and mount the keystore read-only:

server:
    port: 8443
    ssl:
        enabled: true
        key-store: file:/run/secrets/application.p12
        key-store-type: PKCS12
        key-store-password: ${TLS_KEYSTORE_PASSWORD}
        key-alias: application

Provide the password at runtime and adapt the alias to the keystore. These properties configure the embedded server, not an external servlet container. Properties alone do not create simultaneous HTTP and HTTPS connectors; an additional connector requires programmatic configuration. See the Spring Boot SSL guidance, SSL bundles, and multiple Tomcat connectors.

26.5 Ahead-of-Time Processing

Spring’s ahead-of-time (AOT) processing moves bean wiring from startup to build time. Instead of scanning the classpath, reading annotations and evaluating conditions on every boot, the build generates Java source that registers the bean definitions directly, and the application runs that.

Two things follow from it: startup does less work, and the application becomes eligible for a GraalVM native image, which cannot perform the classpath scanning and reflection that normal startup relies on.

AOT is opt-in.

Enabling AOT

Apply the Spring Boot AOT plugin alongside the Grails plugins:

apply plugin: 'org.springframework.boot.aot'

This adds a processAot task, which bootJar then runs as part of the build. Nothing about a normal bootRun or bootJar changes until the application is started with AOT enabled:

java -Dspring.aot.enabled=true -jar build/libs/myapp.jar

An application started this way logs Starting AOT-processed Application rather than Starting Application.

The environment the definitions are generated for

processAot builds the bean definitions the packaged application will use, so it runs in the environment those definitions are meant for. The Grails Gradle plugin sets grails.env to production on the task; nothing needs to be configured for it.

It matters because an application declares different beans in different environments. Under development the URL mappings holder is a proxy whose type cannot be determined without creating it, and generation fails naming a bean unrelated to anything in the application:

Field urlMappings in org.grails.web.mapping.servlet.UrlMappingsErrorPageCustomizer
required a bean of type 'grails.web.mapping.UrlMappings' that could not be found.

Reload-mode beans have no meaning in a packaged artifact, so generating in production is correct as well as necessary. To generate for a different environment, set the property on the task yourself:

tasks.named('processAot') {
    systemProperty 'grails.env', 'staging'
}

What AOT changes at runtime

An AOT-processed application registers the same beans as a normal one. The difference is the annotation-processing infrastructure it no longer needs, since the work those components do has already been done:

  • internalConfigurationAnnotationProcessor

  • internalAutowiredAnnotationProcessor

  • internalCommonAnnotationProcessor

  • Spring Boot’s shared metadata reader factory

  • Grails' own configuration class post-processor

Application beans, plugin beans and auto-configuration beans are all present as usual.

Limitations

Beans contributed from a plugin’s beanRegistrar() are not generated. Spring marks them as registrar-owned and skips them, and they are registered again at runtime when Grails applies the plugin registrars. They behave correctly, but the plugin scan that produces them still runs on every start, so they do not benefit from generation.

Writing bean definitions that hold live objects will fail generation. A definition is a recipe rather than an instance, and there is no general way to generate code that reconstructs an arbitrary object, so a constructor argument or property value holding one cannot be processed:

UnsupportedTypeValueCodeGenerationException:
    Code generation does not support com.example.SomeService

Reference another bean by name instead, or express the collaborator as a nested bean definition, and the generator can emit both.

Abstract bean definitions — templates that exist only to be inherited through bean.parent — are excluded from generation, because Spring has no representation for them in generated code. Definitions that inherit from one are unaffected: they are generated from their merged definition, with the inherited values already folded in.

AOT processing on its own does not make an application ready for a native image. A native image additionally requires reachability metadata covering the resources and reflection an application performs at runtime.

Recording reflection for a native image

Some of that metadata is written for you. The build reads its own output — the compiled artefacts, and the manifest naming each compiled page — and registers them, so a controller, a domain class or a GSP survives into an image without anything having to run.

What that cannot describe is the framework reflecting on its own behalf while a request is served: a controller method reached through Groovy’s dispatch, a conversion asked for while a form is bound. Those are not the application’s classes, so nothing in the build output names them. An image built without them starts, serves its home page, and fails on the first request that takes such a path — with MissingReflectionRegistrationError, naming a method nobody wrote.

traceNativeMetadata runs the application under GraalVM’s tracing agent and records what it actually did:

grails {
    nativeMetadata {
        paths = ['/', '/login', '/book', '/book/create']
        forms = ['/book/create']
    }
}
./gradlew traceNativeMetadata

The result is merged into src/native/resources/META-INF/native-image, alongside the source rather than under build, because which paths an image was built to cover is worth reviewing and worth committing.

paths are asked for. forms are asked for and submitted: the page is read, the fields the form declares are filled in, and they are posted to the action the form names. Submitting matters separately from rendering, because binding a form is a different half of the framework from rendering one — and it is the half that converts. A checkbox arrives as a string and lands on a boolean property, so a form submitted without one never asks the conversion service anything, and the image is built without the answer.

Pages behind a login

Forms are submitted before paths are asked for, whichever order they appear in the configuration, and the session a form establishes is carried through the rest of the trace. That is what makes a protected page traceable: list its login form, and the pages named in paths are reached as a signed-in user.

grails {
    nativeMetadata {
        forms = ['/login?username=admin&password=secret', '/book/create']
        paths = ['/', '/book', '/book/show/1']
    }
}

A form may be told what to put in it with a query string, as /login is above. Only fields the form actually declares are set from it; a value naming a field that is not there is reported rather than posted, because it was meant for a form that has since changed.

Choosing which form

A page often carries more than one form — a layout’s search box or sign-out button ahead of the page’s own. The form declaring the most fields is the one submitted, and the trace output says which it was:

POST /book/save -> 200  (4 fields, from /book/create form 2 of 2, the one with the most fields)

Where the wanted form is not the fullest, name it by its id:

forms = ['/book/create#bookForm']

When the trace fails

The task fails if what was recorded is not what was asked for: a request that answers with an error or a not-found, a page listed under forms that carries no form, a named form that is not on the page, or a form page that answered from somewhere else.

That last one is the case worth knowing about. A form page reached without whatever makes it reachable answers from the login form instead, and every check after that passes — the page it landed on has a form, its fields are filled in, it posts, and it answers successfully. What would be reported is the login form submitted a second time under the name of the page that was wanted, while the binding the page was listed for went unexercised. So a form that answers from anywhere other than where it was asked for fails the trace, and the message names where it landed.

Redirects are followed, so an application that sends / to its real home page records that page. A request that ends up somewhere other than where it was asked for is reported as a warning naming where it landed — usually the sign that a page needs its login form listed under forms.

The agent ships with GraalVM rather than with a JDK, and it must trace the archive the image will be built from — which is compiled for GraalVM’s Java. The task uses the project toolchain, which for a project that builds an image is already a GraalVM; grails.nativeMetadata.javaExecutable points it elsewhere.
The agent records only what ran. A path that is not listed is not covered, and it will be missing in the way that only appears when somebody uses that page. Treat the list as the coverage it is, and extend it as the application grows.

26.6 Ahead-of-Time Caching

Spring’s AOT processing removes the work of deciding what the beans are. What remains is the work the JVM does regardless: loading and linking classes, and interpreting methods until they are compiled. An AOT cache removes most of that as well.

The JDK can record a run of an application — the classes it loaded and linked, and the profiles of the methods it executed — into a cache file, and read that file on the next start instead of doing the work again. The cache is a JDK feature rather than a framework one, so it applies to the whole application: the framework, Spring, Hibernate, and the application’s own classes alike.

It requires JDK 25 or later.

Training is POSIX-only. The cache is written as the training JVM exits normally, so the run has to be asked to stop rather than killed — and a child process on Windows can only be killed. trainAotCache says so and stops rather than training a run it could not end. Reading a cache has no such restriction; only producing one does.

Enabling the cache

The cache is produced by running the application, so it is off unless asked for. Say which pages matter:

grails {
    aotCache {
        enabled = true
        paths = ['/', '/login', '/book/index', '/book/show/1']
    }
}

That adds two tasks. extractAotCacheApplication unpacks the executable jar into build/aot-cache/application, and trainAotCache runs what it unpacked, asks for each path, stops it, and leaves the cache beside the extracted application:

./gradlew trainAotCache
Trained myapp.aot (167 MB) over 4 paths

The application is unpacked first because a cache is read against the layout it was trained on. An executable jar loads its dependencies through a nested-jar classloader; the extracted form loads them as ordinary jars on the classpath, and only the second is a layout a cache can be reused against.

Running with the cache

build/aot-cache is what is deployed: the extracted application, and beside it the cache and what it was trained from.

build/aot-cache/
├── application/          (1)
├── myapp.aot             (2)
└── aot-cache.properties  (3)
1 the extracted application, which is the layout the cache was trained against
2 the cache
3 what the cache was trained from
java -XX:AOTCache=build/aot-cache/myapp.aot -Dspring.aot.enabled=true -Dgrails.env=production \
    -jar build/aot-cache/application/myapp.jar

It does not matter which directory it is started from. Moving the whole of aot-cache elsewhere is fine too — a container image that unpacks it under /opt is the usual case — as long as the cache and application/ move together and keep their timestamps, which the cache checks the archive against. Copying only the jar out of application/ does not work, and not because of the cache: the extracted layout reaches its dependencies through lib/ beside it.

A JVM given a cache it cannot use does not fail. It reports why, ignores the cache, and starts as it would have anyway — so an invalid cache costs the startup time it was meant to save, silently, and without any other symptom.

Adding -XX:AOTMode=on turns that into a failure to start, which is worth doing wherever the cache is the reason the deployment meets its startup time. A cache that has been invalidated then stops the process rather than quietly making it slower:

[0.003s][warning][aot] Unable to use AOT cache.
[0.003s][error  ][aot] Loading static archive failed.

What the training run should do

A cache makes the next start fast at whatever the training run did. Training a run that only refreshes the context records the framework starting, and that alone is most of what the startup time is — so most of the benefit is there before any path is named.

Asking for paths adds a little to that and a good deal more to the first request. Each path asked for during training profiles the code that answers it — URL mapping, the controller, GSP rendering, the data access behind it — so the first real request finds that work done rather than doing it itself.

So paths is worth setting for the pages that matter on a cold start: the ones a load balancer hits first, and the ones a user sees first. An application whose startup time is all that matters can leave it empty and still get most of the benefit.

A path that answers with an error still profiles the code that produced the error, which is code the application runs too, so nothing here fails the build. The run is a recording, not a test.

The training run is a real run of the application. It executes bootstrap code, connects to whatever the configuration points at, and writes whatever that code writes. Point it at a build-time or throwaway database, never at production.

It follows that the application has to be able to start in the environment it is trained in, which is production by default — the environment the cache is for. A datasource that only exists in development is not there during training, and the run fails with whatever the application says when it cannot reach its database:

The training run ended before it started serving.
What it printed is in build/tmp/trainAotCache/training.log

The build fails rather than carrying on, because a cache that was never written is indistinguishable at deployment time from one that was.

Where the defaults are not right, the rest of the run is configurable:

grails {
    aotCache {
        enabled = true
        paths = ['/']
        jvmArguments = ['-Dspring.aot.enabled=true', '-Dgrails.env=production']
        port = 18080
        startTimeoutSeconds = 120
    }
}

jvmArguments is given to the training run and has to be given to every run that reads the cache. A cache records what it saw; a run configured differently from the training run is a different application as far as the cache is concerned, and its cache will be declined.

Because those arguments have to be reproduced at deployment time, they are recorded in aot-cache.properties, which ships beside the cache. Keep credentials out of them and pass them to the training run another way.

Knowing whether a cache still applies

A cache is read only by the JDK build that wrote it, against the archive it was trained on, with the arguments it was trained with. Any of those changing invalidates it, and the only symptom is that startup is no longer fast.

Training runs on the project’s Java toolchain where it declares one, so the JDK recorded here is the one the application is compiled for rather than whichever one happened to run Gradle.

So the training run writes aot-cache.properties beside the cache, recording what it was made from:

cache.file=myapp.aot
cache.bytes=175019584
application.archive=myapp-0.1.jar
application.sha256=6f1b...
training.arguments=-Dspring.aot.enabled=true -Dgrails.env=production
training.paths=/ /login /book/index /book/show/1
java.runtime.version=26.0.2+13
java.vendor=BellSoft
os.name=Mac OS X
os.arch=aarch64

A deployment can compare those values against the JVM it is about to start and the jar it is about to run, and decide whether it has a cache that applies or one that will be quietly ignored. Because the cache is tied to an exact JDK build, a CI pipeline that produces the cache must publish it alongside the JDK it was produced with, and a base image upgrade invalidates every cache built against the previous one.

What it is worth

Measured on a Grails application with GORM for Hibernate, security and asset pipeline, best of three starts on the same machine and JDK:

Start Startup First hit of four cold paths

Ordinary

2.594s

Spring AOT

2.284s

Spring AOT with a cache trained on refresh only

1.015s

0.495s

Spring AOT with a cache trained over those paths

0.933s

0.332s

The shape of that is the point. Spring’s AOT processing on its own moves less than might be expected, because deciding what the beans are was never most of the cost — loading and linking the classes was, and that is what the cache removes, cutting startup by more than half. Naming the paths takes a further tenth off startup and a third off the first requests.

An AOT cache and a native image solve the same problem differently and are not combined: a native image has no cache to read because it has already done all of this at build time. The cache is what an application gets when it stays on the JVM — including applications a native image cannot yet build, which is most of those using Hibernate.