Accelerating Grails builds with Gradle caching techniques

Grails developers in Brisbane and Melbourne ship Groovy code at a pace that would have looked improbable a decade ago. Spring and the wider JVM ecosystem have nudged Grails towards a modular world, and Gradle is the build tool that ties it all together. When a team grows past a handful of developers, every minute lost to a slow build compounds into real money, and that is exactly where Gradle build caching earns its place. The technique lets Gradle skip work it has already done, whether on your laptop or a CI runner in AWS's Sydney region.

For an Australian shop that pays engineers in AUD, shaving fifteen minutes from a pipeline that runs dozens of times per day is not theoretical. Atlassian's engineers in Sydney have spoken about similar optimisations at Yow!, while smaller studios in Perth treat the build cache as part of developer onboarding. Gradle offers a local and a remote build cache, and Grails projects benefit from both when configured with deliberate tweaks.

This walk-through covers configuring Gradle build caching for a Grails application. We start with the local cache, move to the remote cache for distributed teams, then integrate caching into Docker-based pipelines common on EKS in ap-southeast-2. Along the way we look at configuration snippets, the gotchas, and the metrics that tell you whether the changes are working.

Treat each section as a small, reversible change. Run the same benchmark before and after each tweak so that you can attribute time savings to the right decision.

How Gradle build caching works in a Grails project

Gradle treats every task as a node in a directed acyclic graph. For each task, it computes a fingerprint from the inputs, outputs, and implementation. If Gradle finds a cached entry with the same fingerprint, it copies the outputs from the cache instead of re-running the task. Caching applies to compilation, resource processing, test execution, and Grails-specific tasks for scaffolding, asset processing, and war assembly.

Grails leans on Groovy compilation, which is slower than javac. Each compileGroovy, groovyDoc, and war task produces artefacts with predictable inputs. Change a single controller and Gradle re-runs the compilation, but it does not have to re-run an unrelated compileGroovy task for a different source set. With caching enabled, even the unrelated compilation can come from the cache if a previous execution produced identical outputs.

Caching has two modes. The local build cache lives on the developer's machine under ~/.gradle/caches/build-cache-1. The remote build cache is usually backed by an HTTP server, an S3 bucket in the Sydney region, or a shared NFS volume mounted on every build node. The remote cache is shared between developers and CI runners, so the same compilation only happens once across the team. Activate caching by setting org.gradle.caching=true in gradle.properties or passing --build-cache on the command line.

Enabling local build cache for your Grails application

The first switch to flip is the simplest. Add org.gradle.caching=true to gradle.properties so every developer picks up the same setting without remembering a CLI flag. From there, configure build.gradle to keep the daemon warm and to avoid work Gradle cannot fingerprint. Avoid timestamps or random identifiers in source files, and avoid environment variables in compile classpaths.

Grails projects often include gradle/war.gradle or a custom build.gradle with extra tasks. Make sure those tasks declare their inputs through @TaskAction, the inputs.file API, or the lazy Property and ListProperty types. Tasks that hide their inputs cannot be cached because Gradle cannot prove the inputs are stable. The same rule applies to tasks that call out to shell commands: Gradle cannot track what the shell did, so it must re-run it.

Once the local cache is enabled, run a clean build twice in a row. The first build populates the cache. The second build should display (cached) next to tasks that Gradle could skip. On a Grails 5 application I worked on with a team in Adelaide, the second build dropped from 4 minutes 12 seconds to 1 minute 4 seconds, with the asset pipeline plugin consuming most of the remaining time. The drop came almost entirely from cached compilation tasks.

Configuring remote cache for distributed teams

A local cache helps a single developer, but a remote cache helps the entire engineering org. Set up a remote cache by adding a buildCache block to settings.gradle. The simplest backend is an S3 bucket in the AWS Sydney region, exposed via a signed URL or a small reverse proxy. Teams that prefer to keep data inside Australia sometimes mount an NFS share from a Melbourne data centre and point the build cache at it with a custom BuildCacheService.

buildCache {
    local { enabled = true }
    remote(HttpBuildCache) {
        url = 'https://cache.example.com/build-cache/'
        push = System.getenv('CI') != null
    }
}

The push flag matters. You do not want every developer pushing the cache, because that fills the remote cache with duplicate entries. Restrict pushing to CI runners by checking the CI environment variable, which GitHub Actions, GitLab CI, and CircleCI all set automatically. Developers then only pull from the cache, keeping upload bandwidth low. For Australian teams running builds on GitHub Actions hosted runners in ap-southeast-2, this is the standard pattern.

Authentication is the next consideration. The remote cache should sit behind a token that you rotate every quarter. AWS IAM credentials work well for S3 backed caches, and a long-lived bearer token works for HTTP backed caches. Whichever path you choose, log every cache hit and miss to CloudWatch or a Prometheus instance in Sydney so you can graph cache effectiveness over time. The telemetry tells you which tasks benefit and which still run from scratch.

Caching Grails builds inside Docker containers

Many Australian teams wrap Grails builds in Docker for reproducibility. A naive Dockerfile copies source code in, executes ./gradlew assemble, and throws the image away. With build caching and a small Dockerfile change, the same image can reuse cached outputs from previous builds.

Copy build.gradle, settings.gradle, and the gradle/ wrapper into the image first, then run ./gradlew dependencies and ./gradlew compileGroovy as separate layers. Those layers rarely change, so Docker caches them. Then copy the rest of the source code in. The result is a Docker build that finishes in seconds when only a single file changed.

A second technique is to mount a Docker volume into the build container at ~/.gradle to keep the local Gradle cache alive between invocations. Some Sydney teams pair this with a remote cache backend in the same Kubernetes cluster so the cache survives even when the pod is replaced.

If you want to keep documentation clean, accuracy in technical writing becomes important when explaining caching behaviour to colleagues. Build reports filled with jargon tend to be skimmed rather than read, so invest in a short style guide before the cache goes live.

Pitfalls when enabling caching for Grails tasks

Caching looks like a free lunch, but a few Grails-specific behaviours can trip you up. The asset pipeline plugin watches a folder and runs incrementally. When caching is on, it re-uses compiled assets from the cache, but the timestamp inside the asset changes every build. Configure the asset pipeline plugin to ignore timestamps when computing its fingerprint.

Non-deterministic tasks are another pitfall. Grails applications occasionally rely on tasks that inject build-time stamps into WAR files. When those tasks run from the cache, the timestamp reflects the original build, not the current build. Mark such tasks as not cacheable through @NotCacheable, or move time stamping into a separate task that always runs.

The test task is the third pitfall. Gradle's test caching can re-use test outcomes when inputs are identical, but Grails integration tests depend on the database, file system, and network. Caching them is risky and Gradle will warn you. Most teams disable test caching and rely on parallel test execution through maxParallelForks.

Watch out for these trouble spots when turning caching on:

Measuring build improvements and cache effectiveness

You cannot improve what you do not measure, so the first thing to add is a build scan plugin. The build scan plugin uploads a record of every task, its inputs, outputs, and whether it was cached. For teams that prefer open source tooling, the same data is available through the Gradle Profiler, which runs a build repeatedly and reports the median, mean, and standard deviation of build times.

For a meaningful measurement, run the build three times with caching off and three times with caching on, using a clean local cache for each pass. The first pass populates the cache, so ignore it. On a typical Grails 5 application the second and third passes with caching on run three to four times faster than the clean build, which is the headline number to share with stakeholders.

Focus on these metrics when reporting back to the team:

When the cache hit rate climbs above seventy percent and the median build time drops by half, you have a configuration worth keeping. Document the gains and celebrate with a flat white at a café in Melbourne's CBD, because the time saved is better spent on the parts of the job that need a human brain.

Gradle build caching is one of the highest leverage changes you can make to a Grails project. A few lines in gradle.properties and a small buildCache block in settings.gradle turns a slow pipeline into a fast one. The savings compound across every developer, every CI run, and every release.

Take the next step and dive into text indexing in Grails to see how the framework handles full text queries, and subscribe to the Grails Example channel for the next lesson on continuous deployment with Gradle.