Deploying Grails applications on AWS Lambda for serverless workflows
Across the Asia-Pacific region, Australian engineering teams have been quietly migrating business workloads away from always-on EC2 fleets toward event-driven platforms. Companies in Sydney, Melbourne, and Brisbane — from fintechs in the CBD to logistics firms in Perth — increasingly choose AWS Lambda because it removes the operational tax of patching, scaling, and watching Tomcat JVMs overnight. For developers who already build with Grails, the question is no longer whether the framework can survive outside a traditional servlet container, but how to engineer the deployment so startup time, memory ceiling, and database connectivity behave predictably inside Lambda's execution environment.
The local AWS infrastructure footprint makes this transition easier than it once was. With the Sydney region (ap-southeast-2) opened in 2012 and the Melbourne region (ap-southeast-4) launched more recently, Australian teams can keep traffic, logs, and data residency close to their customers without crossing the Pacific. The Privacy Act 1988 and the Notifiable Data Breaches scheme encourage — and in some sectors compel — keeping personal information onshore. Running Grails on Lambda inside ap-southeast-2 satisfies many of these obligations out of the box, particularly when paired with RDS instances or Aurora Serverless clusters in the same region.
What separates a workable Grails-on-Lambda deployment from a flaky one is rarely the framework itself. Grails 5 and 6 already run on the JVM and Groovy, both of which Lambda can host through a custom runtime or a provided.al2023 base image. The challenges live in the periphery: how Hibernate sessions behave when a container is frozen, where uploaded files land when /tmp disappears after a freeze, and how to keep cold start latency acceptable when your checkout flow competes against a California-based rival. Each of these problems is solvable with deliberate engineering.
What follows is a practical walk-through of the moving parts: packaging, runtime selection, database access, performance tuning, and operational observability. Readers who prefer a guided path can review the broader course outline on the site, which covers these topics in a sequenced order alongside the supporting video lessons.
Packaging a Grails build for Lambda deployment
The first engineering decision is whether to deploy Grails as a fat JAR, a container image, or a ZIP of pre-compiled classes. Australian teams tend to weigh each route against existing CI pipelines, which often run on Bitbucket Pipelines or GitHub Actions in Sydney.
The ZIP route remains the lightest option for typical CRUD applications. Container images lift that ceiling to 10 GB, attractive for projects with large static asset catalogues or native dependencies such as an OCR binding for an invoice-scanning module. Teams already publishing Docker images to Amazon ECR often pick the image route to consolidate supply-chain tooling.
Packaging approaches worth comparing
- Fat JAR with shaded dependencies uploaded as a single artefact
- Container image published to Amazon ECR for larger classpaths
- Pre-compiled ZIP uploaded directly through the Lambda console
A gradle assemble followed by an explicit repackaging step that strips redundant jars produces an artefact that fits Lambda's 250 MB unzipped limit. A minimal handler in Groovy looks like this:
class LambdaHandler implements RequestHandler<APIGatewayProxyRequestEvent, APIGatewayProxyResponseEvent> {
GrailsApp app
LambdaHandler() {
app = new GrailsApp(GrailsLauncher.configuration)
app.refresh()
}
@Override
APIGatewayProxyResponseEvent handleRequest(APIGatewayProxyRequestEvent input, Context context) {
// delegate to a service
}
}
The class wires through a provided.al2023 runtime as the handler entry, or via a CMD directive when running as a container. Cold start cost is dominated by the first app.refresh() call, so production deployments move context initialisation into a static init block guarded by a Once flag in DynamoDB — a change that routinely trims a 3.5-second cold start down to around 1.8 seconds in Melbourne workloads.
Keep dependencies lean during packaging. Many teams discover that the generated WAR pulls in assets they never reference — sample controllers, Bower webjars, plugin metadata. Pruning pays off twice: smaller artefacts upload faster, and smaller cold containers thaw faster.
Bridging Grails with a custom runtime
Stock Java runtimes on Lambda work fine for pure Spring Boot or Micronaut services, but Grails benefits from a custom runtime that exposes finer-grained control over the GrailsApplication instance. The framework relies on the ApplicationContext for bean wiring, dependency injection, and GORM transaction management. Treating that context as a per-invocation object is wasteful; treating it as a JVM-wide singleton across warm invocations is the goal.
A typical custom runtime shell script imports the bundled JVM, sets the Grails home directory, and forwards requests to a thin HTTP server that delegates into Groovy. The HTTP server can be a Jetty instance, a Micronaut-based router, or — increasingly popular among Australian consultancies — a small Undertow wrapper the team maintains internally. Because the runtime stays warm between invocations, the GrailsApplication remains initialised, and warm-start latency drops to single-digit milliseconds for most controllers.
The DevOps cycle differs from a normal Tomcat deployment in two ways. Environment configuration moves into AWS Systems Manager Parameter Store or Secrets Manager — database credentials and third-party API keys belong in Secrets Manager with rotation enabled. aws lambda update-function-code on every commit works in low-traffic shops, but teams serving customers across Adelaide, Hobart, and the Gold Coast typically reach for CodePipeline with a manual approval stage before traffic shifts.
Keep the HTTP wrapper stateless. Session state belongs in DynamoDB or ElastiCache — relying on in-memory state between invocations produces cold-start failures that test suites rarely catch. Publishing your internal runtime publicly follows the trajectory described in the open source release journey that Phil Schwartz walked through for DenyHosts.
Managing cold starts, memory, and database connections
Cold starts dominate the perceived performance of any Grails-on-Lambda deployment, and they are the most common cause of frustration when a project leaves the prototype phase. For Australian users browsing during evening peaks in AEST — typically between 6pm and 10pm — a 4-second cold start feels broken. For developers in Asia-Pacific markets reaching the API from Singapore or Tokyo, latency on top of a cold start pushes total response times past the patience threshold of an impatient mobile app.
Knobs that govern cold starts in practice
- Memory allocation tied to CPU billing across the AWS price sheet
- Reserved versus provisioned concurrency for warm container pools
- Classpath size after pruning unused plugins and webjars
Raising memory from 512 MB to 1024 MB typically halves cold-start time; most Grails workloads settle between 1024 MB and 2048 MB. Provisioned concurrency is the most expensive but effective remedy for chronic cold starts, pairing well with a latency budget targeted at the 99th percentile. The classpath size knob wins back milliseconds that no amount of CPU allocation recovers, particularly when GORM and the asset-pipeline plugin stack together.
Database connections need careful handling. Hibernate opens connections lazily, but a Lambda container frozen for an hour returns broken sockets on its first query. Common remedies include a Connection.isValid() probe at handler entry, HikariCP tuned for maxLifetime shorter than AWS's idle disconnect window, and routing traffic through RDS Proxy when load grows beyond a few dozen concurrent users. Australian teams running PostgreSQL on Aurora Serverless v2 in ap-southeast-2 report the smoothest experience after moving to RDS Proxy, since pooled connections survive the Lambda freeze cycle cleanly.
Data persistence beyond a frozen container
The /tmp directory on Lambda offers up to 10 GB of ephemeral storage, but it disappears the moment a container is reclaimed. Any logic that writes a temp file expecting to read it on the next invocation will silently break. The discipline is to treat Lambda as stateless from the application developer's perspective, with S3 acting as the default scratch space for anything that needs to survive across invocations.
For projects that need binary upload handling, the pattern in Australia often centres on S3 pre-signed URLs. The Grails controller validates a token, returns a pre-signed PUT URL, and the browser uploads directly to the bucket. The Lambda function never touches the file payload; it only writes the resulting metadata into DynamoDB after receiving an S3 event notification. This architecture scales linearly with uploaded file size without pressuring the Lambda memory limit, working equally well whether customers upload from Sydney, Perth, or regional New South Wales.
Search indexing follows the same logic. If the application indexes documents into Elasticsearch or OpenSearch, push the index operation into an asynchronous SQS queue rather than running it inline. The processing Lambda can then be tuned independently of the API-facing handler, keeping cold-start times honest for customer-facing requests. This separation is what mature Australian SaaS vendors — Atlassian-adjacent integrators and Canberra-based government contractors — rely on to keep API latency budgets intact during background-heavy jobs.
Schema migrations need their own discipline. Liquibase or Flyway changes should run as a separate one-shot Lambda task triggered by CodePipeline on deploy, rather than as part of application startup. Holding migrations inside BootStrap.groovy looks convenient until a parallel deploy races an in-flight migration during a Friday afternoon outage window.
Observability, cost, and performance tuning
Once the application is in production, the work shifts from writing code to understanding behaviour. CloudWatch metrics surface invocation count, error rate, duration, and throttles for free; everything else requires deliberate instrumentation. Structured logging with a JSON encoder — Logback's logstash-logback-encoder is popular in the Australian Groovy community — lets you pipe events straight into OpenSearch or a third-party SaaS such as Datadog.
Distributed tracing becomes essential when a request hops between API Gateway, Lambda, DynamoDB, and an RDS Proxy. AWS X-Ray offers solid baseline coverage, but fine-grained GORM spans often need manual instrumentation. A single slow Hibernate query can turn a 40 ms p95 into a 1.4 s p95 without any visible CloudWatch error. For deeper work on tuning Hibernate, GORM caching, and JVM-level profiling, the platform's optimizing-grails-application-performance-with-profiling article walks through method-level sampling and CPU flame graphs.
Cost discipline matters more than many first-time deployments expect. Lambda charges per gigabyte-second, and a misconfigured memory setting can quietly inflate a monthly bill by a factor of three within weeks. Teams serving Australian customers from ap-southeast-2 should also keep an eye on inter-region transfer — overseas transfer bills tend to dwarf compute bills in unexpected ways.
Your next serverless deployment deserves a workspace built for experimentation, and the community around this site is ready to help. Subscribe to the Grails Example newsletter for fresh tutorials, deploy recipes, and updates on new video lessons delivered every fortnight. Subscribers receive early access to deep-dive Lambda modules and quarterly livestreams covering production pitfalls.
Join the conversation in the site comments, follow the quarterly release recaps, and bring your team along to the next livestream for live walk-throughs of community-submitted deployments across Australia and beyond. The moderation team reviews every submission and replies with code-level answers.