Running Grails with Docker Compose for Local Development

Developing a Grails application on your laptop should feel light, repeatable and fast. Too often, however, the gap between a developer's machine and a staging environment turns small changes into long debugging sessions. Docker Compose closes that gap by packaging the Grails runtime, the JVM, the database and any supporting services into a single declarative configuration that anyone on the team can spin up with one command.

For teams working across Sydney, Melbourne and Brisbane, the appeal is obvious. A new starter in Perth can clone a repository, run docker compose up, and have the same running stack as a senior developer in Surry Hills without paging through pages of setup notes. The containerised workflow also travels well over the National Broadband Network, where upload speeds in suburban areas often lag behind downloads, because the heavy lifting happens once when images are built and cached locally.

This walkthrough focuses on practical decisions rather than theoretical benefits. You will see how to build a Grails image, wire it into a multi-service Compose file, persist database state across restarts, and debug live code from your IDE. The aim is a local environment that feels close to production, without the ceremony of a full deployment pipeline.

Preparing Your Project for Containerisation

Before writing a Dockerfile, take stock of what your application actually needs. A standard Grails 5 project on Groovy 3 typically requires a JDK, the Grails wrapper, a build cache, and connection details for a backing database. Inventorying these dependencies first prevents bloated images and painful rebuilds later. It is also a good moment to decide which environment variables should live in a .env file, since hard-coding credentials creates friction when multiple engineers share a development database hosted in another state.

Most Australian teams now standardise on OpenJDK 17 or 21 because Grails 5 and 6 both run cleanly on these versions and they are the long-term supported distributions available through Adoptium. Pinning the JDK in your build pipeline also reduces the surprise of a colleague pulling a different base image. If your project still relies on a JDK 8 toolchain, that is worth flagging early, because the older base images often pull from archived repositories and may not match what your cloud provider offers.

A small but useful habit is to keep a dedicated .dockerignore file at the project root. Excluding .git, build, .gradle and node_modules keeps the build context small, which matters more in Australia where some team members connect over FTTN or fixed wireless links with limited upstream capacity. A smaller context also speeds up CI runs when the same image recipe is later promoted to a shared registry.

Building a Lean Grails Image

The Dockerfile for a Grails service does not need to be exotic. A common pattern is a multi-stage build where the first stage compiles the application and the second stage holds only the runtime artefacts. This keeps the final image free of Gradle caches and source code, which is good for both security and size. Choose a slim base such as eclipse-temurin:17-jre-jammy and copy the built WAR or JAR from the builder stage into a fixed location.

Layer caching matters more than people expect. By copying the build files first and running a dependency resolution step before adding source code, you preserve the dependency layer across rebuilds where only your own code changes. In a team where engineers iterate on controllers and services throughout the day, this single habit can shave several minutes off each container rebuild. The same principle applies to the Grails wrapper jar: if its checksum changes frequently in your lockfile, cache misses will be common and that is worth knowing up front.

Pay attention to the user the container runs as. Running as root inside a development container is tempting for simplicity, yet it causes file ownership headaches on bind-mounted volumes, particularly on macOS and Linux workstations. Creating a non-root user in the Dockerfile and switching to it before the entrypoint saves a lot of chown commands later. For Windows hosts with WSL2, the same advice applies, because the virtualised filesystem does not appreciate world-writable bind mounts.

Defining Multi-Service Workflows

A docker-compose.yml file is where the real leverage comes in. Instead of a single container running Grails in isolation, you describe the full local stack: the app service, a Postgres instance, perhaps a Redis cache, and any auxiliary tools such as MailHog or an Adminer container for browsing data. Each service gets its own block, its own image or build context, and its own set of environment variables sourced from a shared .env file.

Networks in Compose are simple by default. All services on the same project network can reach each other by service name, which means your Grails datasource can point to jdbc:postgresql://db:5432/orders instead of an IP address. This naming convention is reliable across Linux, macOS and Windows hosts because Compose provisions an internal network for the project. Health checks add resilience: a depends_on block with a condition of service_healthy prevents the app from starting until Postgres reports ready, eliminating race conditions on cold starts.

When developers are spread across cities, the network performance between services running on the same host is rarely the bottleneck. The interesting question is whether to share a remote development database or run one locally per engineer. For most Grails projects of modest size, a local Postgres container is the right call. It avoids the latency of reaching a shared database in another region and removes a class of "it works on my machine" bugs caused by divergent data sets. Reserve remote shared databases for staging and integration testing.

Managing Databases and Persistent State

Containers are ephemeral by design, which is wonderful for reproducibility but awkward when you want to keep yesterday's test data. Named volumes solve this neatly. Declaring a volume attached to Postgres ensures that schema migrations, seed data and any manual SQL experiments survive a docker compose down. Bind mounts work too, but they expose host paths into the container, which can produce permission issues on macOS when the container's Postgres user cannot write to a directory owned by the host user.

Schema migrations deserve special attention in a containerised workflow. Grails database-migration plugin commands can run as a one-off container through Compose, or as an init container in production. Running them locally mirrors what happens on deploy, which catches migration ordering bugs before they reach a shared environment. For teams handling customer information, this habit also supports compliance with the Australian Privacy Principles, because changes to data structures can be reviewed in the same context where they will be applied.

Backups matter even in development. A small script that dumps the Postgres volume to a timestamped file gives engineers a safety net when an experimental migration goes wrong. Storing those dumps outside the project directory, perhaps in a cloud bucket governed by your organisation's retention rules, keeps the local checkout clean and aligns with broader data-handling policies. Australian businesses that process personal information under the Privacy Act 1988 benefit from this kind of repeatable backup story, even at the development stage.

Debugging, Hot Reload and File Mounts

Live reload is the single biggest productivity boost a containerised Grails setup can offer. By bind-mounting the project directory into the container and starting the application with grails -reloading run-app, code changes on the host appear immediately inside the JVM. The catch is that Gradle's build cache and the Grails working directory need to be writable inside the container, which is where the non-root user pays off. Some teams instead use the spring-boot-devtools approach with a restart trigger file, which is more reliable when working across slower links like FTTN where file change events can arrive in bursts.

Remote debugging through JDWP is straightforward in Compose. Exposing port 5005 and setting JAVA_TOOL_OPTIONS to include the JDWP transport arguments lets IntelliJ IDEA or VS Code attach to the running container as if it were local. Breakpoints, expression evaluation and hot swap all work normally. This setup is particularly valuable when the bug only reproduces under specific container networking conditions, such as when a service is calling another service by its internal DNS name.

Logging and stdout deserve a mention. Compose captures logs from each service and prefixes them with the service name, which is far easier to read than tailing multiple terminals. Setting LOGGING_LEVEL_ROOT=DEBUG through the environment for a specific run keeps verbose output contained to the session you actually need it for, rather than committing debug flags to source control where they will haunt production.

Networking, Ports and Local Access

Most Grails apps run on port 8080 by default, and mapping that to the host in Compose is as simple as "8080:8080". The interesting decisions appear when multiple projects share a workstation or when you want to expose additional services. MailHog on 1025, Adminer on 8081 and RedisInsight on 8001 are common companions, and assigning each a distinct host port prevents clashes. A short comment in the Compose file explaining the port allocation saves colleagues ten minutes of digging through conflicting processes.

Hostnames inside the Compose network differ from hostnames on your laptop. The app container can reach db or redis by service name, but a browser on the host reaches the app at localhost or 127.0.0.1. This distinction is worth documenting for new team members who may be running their first containerised stack. If your application needs to call back into itself, for instance during a webhook test, you can rely on host.docker.internal on Docker Desktop for macOS and Windows; on Linux the same address works in newer engine versions but may need to be added explicitly through extra_hosts.

For Australian developers working from home, port forwarding on consumer routers occasionally blocks traffic to common development ports. Choosing less common ports for admin UIs, such as 8025 instead of 1025 for MailHog's web interface, sidesteps ISP-side filters. This is a small accommodation that makes remote pairing sessions smoother when one engineer is on a hotel Wi-Fi connection in Adelaide and another is at home in Canberra.

Mirroring Production for Safer Releases

The ultimate test of a local environment is how confidently you can promote code to staging. When the Compose stack mirrors production closely, the surprises that used to surface only after deploy start appearing during development instead. Same JVM version, same database engine, same connection pool sizing, same health endpoint. None of this requires a heavy investment, just discipline in the Compose file and a willingness to update it as production evolves.

Environment parity also helps when incidents occur. If a developer can reproduce a customer-reported issue locally by spinning up the exact same database version and the same Redis cache, the feedback loop shortens dramatically. For teams operating across Australian time zones, that loop compression matters because an outage at 6 pm AEST should not require waking someone in another region to investigate.

Treat the Compose file as part of the application, not as a developer convenience. Review it in pull requests, version it alongside the code, and document any service that depends on a specific external system. When the local environment is treated with the same care as the deployment manifest, the gap between development and production narrows, and Grails applications ship with fewer late surprises.

Practical Recommendations for Local Compose Setups

If you want a deeper dive into related patterns, including packaging Grails applications for cloud hosting and tuning them for Australian latency profiles, the Grails Example magazine publishes regular walkthroughs written by working engineers.