Dockerize a Micronaut Application

Khimananda Oli 8 min read Programming and Languages
Dockerize a Micronaut Application

By Khimananda Oli | Last reviewed: August 2026

You need to dockerize a Micronaut application that starts instantly, consumes minimal memory, and passes security audits without bloating your CI pipeline. While standard Java containers work, they often carry unnecessary build artifacts and runtime overhead that hurts scaling costs and cold-start performance. This guide walks you through creating an optimized, secure container image using multi-stage builds and GraalVM native compilation specifically tuned for Micronaut’s architecture.

Source CodeMicronaut + GradleBuild StageJDK 21 + Shadow JARNative CompileGraalVM ReachabilityRuntime ImageAlpine / Distroless
High-level workflow to dockerize a Micronaut application: source → build → native compile → minimal runtime

How do you create a multi-stage Dockerfile to dockerize a Micronaut application?

A multi-stage build is non-negotiable when you dockerize a Micronaut application for production. It separates heavy build dependencies from the lean runtime artifact, reducing attack surface and image size by over 80%. The first stage compiles your code and produces either a fat JAR or a native binary; the second stage copies only that artifact into a minimal base image.

Standard JVM-based Dockerfile

If you are not yet ready for native compilation, start with this reliable pattern. It uses Eclipse Temurin for consistent OpenJDK builds and Alpine Linux for a small footprint. This approach typically yields images around 200–250MB.

# Stage 1: Build
FROM eclipse-temurin:21-jdk-alpine AS builder
WORKDIR /app
COPY . .
RUN ./gradlew assemble --no-daemon

# Stage 2: Runtime
FROM eclipse-temurin:21-jre-alpine
RUN addgroup -S micronaut && adduser -S micronaut -G micronaut
WORKDIR /home/micronaut
COPY --from=builder /app/build/libs/*-all.jar app.jar
USER micronaut
EXPOSE 8080
ENTRYPOINT ["java", "-XX:+UseContainerSupport", "-jar", "app.jar"]

The --no-daemon flag prevents Gradle from spawning background processes inside the container, which can cause build hangs. Always enable -XX:+UseContainerSupport so the JVM respects cgroup memory limits — without it, your container may be OOM-killed despite having sufficient allocated resources.

GraalVM Native Image Dockerfile

For serverless or high-density deployments where startup time matters, compile to native. Micronaut provides excellent GraalVM support out of the box. Use the official GraalVM JDK image for compilation, then copy the resulting static binary into a scratch or distroless container.

FROM ghcr.io/graalvm/native-image-community:21 AS native-builder
WORKDIR /app
COPY . .
RUN ./gradlew nativeCompile --no-daemon

FROM gcr.io/distroless/static-debian12
COPY --from=native-builder /app/build/native/nativeCompile/app /app
USER nonroot:nonroot
EXPOSE 8080
ENTRYPOINT ["/app"]

Distroless images contain no shell, package manager, or extraneous binaries. This makes exploitation significantly harder post-compromise. If you need debugging capabilities during development, swap to alpine:3.19 temporarily but never ship it to production.

Why should you choose GraalVM native image when you dockerize a Micronaut application?

Micronaut was designed with ahead-of-time (AOT) compilation in mind, unlike Spring Boot which relies heavily on reflection at runtime. When you dockerize a Micronaut application as a native image, you eliminate JVM warm-up entirely. Startup drops from 2–5 seconds to under 100ms, and RSS memory usage falls from ~300MB to ~50MB. This directly translates to lower cloud bills and better autoscaling responsiveness.

JVM Mode: 3.2s startup | 280MB RSSNative: 0.08s | 45MB40x faster startup6x less memoryTrade-offs to consider:• Build time increases 3–5 minutes per compile• Reflection-heavy libraries require explicit configuration• Debugging requires different tooling (no jstack/jmap)• Ideal for: Lambda, Cloud Run, K8s HPA, edge compute
Performance comparison when you dockerize a Micronaut application: JVM vs GraalVM native image trade-offs

However, native compilation isn’t free. Build times increase significantly, and some third-party libraries require manual reflection configuration via reflect-config.json. Test thoroughly in staging before committing. For teams managing multiple services, I recommend maintaining both JVM and native Dockerfiles in the same repo, switching via build args based on target environment. Read more about reducing Docker image size with multi-stage builds for additional optimization techniques applicable here.

What security hardening steps are required when you dockerize a Micronaut application?

Security cannot be an afterthought when you dockerize a Micronaut application, especially if handling PII or operating under SOC 2 / ISO 27001 compliance. Every layer of the container must follow least-privilege principles.

  1. Run as non-root: Never execute your application as UID 0. Create a dedicated user in the Dockerfile and switch to it before ENTRYPOINT. Distroless handles this automatically with the nonroot user.
  2. Read-only root filesystem: Mount tmpfs at /tmp and set readOnlyRootFilesystem: true in Kubernetes pod specs. Prevents attackers from writing malicious scripts post-exploitation.
  3. Drop all capabilities: In your orchestration manifest, explicitly drop ALL Linux capabilities and add back only NET_BIND_SERVICE if binding to privileged ports (rare for Micronaut).
  4. Scan every build: Integrate Trivy or Grype into your CI pipeline. Fail the build on HIGH/CRITICAL CVEs. Sign images with Sigstore Cosign for supply chain integrity.
  5. Pin base image digests: Replace mutable tags like alpine:3.19 with SHA256 digests to prevent tag-squatting attacks and ensure reproducible builds.

These controls align with CIS Docker Benchmark v1.6 and are routinely checked during external audits. Skipping even one creates findings that delay certifications. For broader context on securing containerized workloads, see Kubernetes security: pod security and network policies.

How do you optimize health checks and observability when you dockerize a Micronaut application?

Containers are ephemeral; your orchestration platform needs reliable signals to route traffic and trigger restarts. Micronaut exposes standardized endpoints that integrate seamlessly with Docker HEALTHCHECK and Kubernetes probes.

EndpointPurposeDocker HEALTHCHECKK8s Probe Type
/health/livenessProcess alive, no deadlockCMD curl -f http://localhost:8080/health/liveness || exit 1livenessProbe
/health/readinessReady to accept traffic (DB connected, caches warm)Not recommended (use readiness probe instead)readinessProbe
/prometheusMetrics exposition for scrapingN/AN/A (ServiceMonitor)

Enable these endpoints in application.yml:

endpoints:
  health:
    enabled: true
    sensitive: false
    details-visible: ANONYMOUS
  prometheus:
    enabled: true
micronaut:
  metrics:
    export:
      prometheus:
        enabled: true

In native mode, ensure health endpoints are included in the reachability metadata. Micronaut’s GraalVM processor usually handles this automatically, but verify with integration tests. Pair this setup with structured logging practices outlined in structured logging best practices to correlate logs, metrics, and traces effectively across containerized services.

KubeletMicronaut AppPrometheusIngress / LBGET /health/liveness200 OKScrape /prometheusRoute traffic only if readyKey Configuration in Deployment YAML:livenessProbe: { httpGet: { path: /health/liveness, port: 8080 }, initialDelaySeconds: 5 }readinessProbe: { httpGet: { path: /health/readiness, port: 8080 }, periodSeconds: 10 }startupProbe: { httpGet: { path: /health/liveness, port: 8080 }, failureThreshold: 30 }⚠ Native image note: Set initialDelaySeconds ≥ 2s to allow AOT initialization✅ Always test probes locally: docker run -p 8080:8080 my-micronaut-app && curl localhost:8080/health
Health check and observability flow after you dockerize a Micronaut application for Kubernetes

How do you integrate CI/CD when you dockerize a Micronaut application?

Automation eliminates drift between environments. Your CI pipeline should build, test, scan, and push the container image in a single atomic workflow. Below is a GitHub Actions snippet optimized for Micronaut native builds:

name: Build & Push Micronaut Container
on: [push]
jobs:
  build:
    runs-on: ubuntu-latest
    permissions:
      contents: read
      packages: write
    steps:
      - uses: actions/checkout@v4
      - name: Set up QEMU (multi-arch)
        uses: docker/setup-qemu-action@v3
      - name: Build & Push
        uses: docker/build-push-action@v6
        with:
          context: .
          push: true
          tags: ghcr.io/${{ github.repository }}:${{ github.sha }}
          cache-from: type=gha
          cache-to: type=gha,mode=max
      - name: Scan with Trivy
        uses: aquasecurity/trivy-action@master
        with:
          image-ref: ghcr.io/${{ github.repository }}:${{ github.sha }}
          severity: 'HIGH,CRITICAL'
          exit-code: '1'

Cache layers aggressively using GitHub Actions cache backend — native builds benefit enormously from cached dependency and compilation layers. Tag images with Git SHA, not latest, to ensure traceability during incident response. Store secrets in GitHub Secrets or Vault, never in Dockerfiles or layer history.

Final Checklist Before You Ship

Before promoting any containerized Micronaut service to production, verify these items. They reflect lessons from real audit failures and outage postmortems I’ve led across AWS EKS and on-prem Kubernetes clusters serving Nepal-based fintech platforms.

  • Image runs as non-root with dropped capabilities
  • Health endpoints respond correctly in native mode
  • Trivy scan passes with zero HIGH/CRITICAL vulnerabilities
  • Memory limits match observed RSS + 20% headroom
  • Logs output JSON to stdout for centralized aggregation
  • Configuration externalized via ConfigMaps/Secrets, not baked in
  • Rollback strategy tested (previous image tag retained in registry)

When you dockerize a Micronaut application correctly, you gain predictable performance, stronger security posture, and lower operational overhead. Start with the JVM multi-stage Dockerfile, validate behavior, then graduate to native compilation once your test suite covers reflection edge cases. Need help designing a compliant container platform or optimizing existing Java microservices? Reach out to discuss your infrastructure.

Frequently Asked Questions

Use eclipse-temurin:21-jre-alpine for production builds. It provides a minimal footprint under 90MB while supporting GraalVM native image compilation and modern Java 21 virtual threads required by current Micronaut versions.

Add the micronaut-aot and graalvm features via Micronaut Launch or CLI. Configure your Dockerfile with a multi-stage build using ghcr.io/graalvm/native-image-community:21 as the builder stage to compile the binary before copying it to a runtime container.

Native images require explicit reflection configuration. Missing resource hints cause silent failures during bean initialization. Run the native-image agent locally to generate reflect-config.json, then include it in your META-INF/native-image directory before rebuilding the container.

Yes. Separate dependency resolution from compilation in your Dockerfile. Copy gradle.lockfile or pom.xml first and run dependency download tasks before copying source code. This ensures cached layers persist across code changes, reducing rebuild times significantly in CI pipelines.

Expose port 8080 by default. Micronaut binds to this port unless MICRONAUT_SERVER_PORT is overridden. Always map internal ports explicitly in docker-compose rather than relying on host networking for predictable service discovery.

Use MICRONAUT_ENVIRONMENTS environment variable to activate profiles like docker or prod. Mount external YAML files to /app/config or inject secrets via environment variables. Never bake sensitive credentials directly into the image layer during the build process.

Jib eliminates Docker daemon dependencies and optimizes layering automatically for JVM apps. However, it lacks native image support without custom extensions. Choose standard Dockerfiles for GraalVM builds and Jib for pure JVM deployments requiring fast CI iteration.

Set JAVA_TOOL_OPTIONS with -XX:MaxRAMPercentage=75.0 for JVM containers. For native images, configure --gc=G1 and limit heap via -Xmx flags. Monitor actual RSS usage with docker stats since container limits differ from JVM-perceived available memory.

Container memory limits constrain the JVM differently than bare metal. Without proper cgroup awareness, Java allocates based on host memory. Enable UseContainerSupport flag and set MaxRAMPercentage to prevent the JVM from exceeding Docker memory constraints during garbage collection cycles.

Enable JDWP by adding -agentlib:jdwp=transport=dt_socket,server=y,suspend=n,address=*:5005 to JAVA_OPTS. Expose port 5005 in your compose file. Attach your IDE remotely but never enable debug agents in production images due to security risks.

No. Create a non-root user named micronaut with UID 1000 in your Dockerfile. Switch to this user before the ENTRYPOINT instruction. Running as root violates CIS benchmarks and increases blast radius if the container is compromised.

Configure logback.xml to write JSON formatted logs to stdout only. Avoid file appenders since containers are ephemeral. Let your orchestrator collect stdout streams. Include trace IDs and structured fields for correlation in distributed tracing systems like OpenTelemetry.

Usually under fifty milliseconds.

Integrate Trivy or Grype into your CI pipeline post-build. Scan both OS packages and Java dependencies. Fail builds on HIGH or CRITICAL CVEs. Update base images monthly and regenerate SBOM artifacts for compliance auditing requirements.

Yes. Container restarts reset all connections. Configure HikariCP with validationTimeout and testOnBorrow enabled. Set maximumPoolSize relative to CPU quota not host cores. Implement graceful shutdown hooks to close pools cleanly during SIGTERM signals from orchestration platforms.