08 — Deployment
In this chapter:
- Build a production JAR and run it locally
- Run the whole stack (app + database) with Docker Compose, and understand the Dockerfile in depth
- Build a GraalVM native image, time the startup difference, and compare image sizes
- Set production-safe configuration
- (Optional) Deploy to Azure Container Apps with a managed PostgreSQL database
This is a tour, not a full DevOps module. By the end, you'll have run your app three different ways and know how to take it to a real cloud.
1. Build a production JAR
So far you've been running in development mode (./mvnw spring-boot:run). Production mode produces a single executable JAR with an optimized front-end bundled inside.
./mvnw clean package
The Maven Frontend Plugin runs npm run build as part of the normal generate-resources phase, so a plain package already produces minified Vite output with hashed filenames — there is no -Pprod profile to remember. The result:
ls target/*.jar
# target/todo-app-0.0.1-SNAPSHOT.jar
You can run this JAR anywhere Java 25 is installed — no Maven, no Node, nothing else:
# Start Postgres first (compose.yaml still works standalone)
docker compose -f compose.yaml up -d postgres
# Run the JAR
java -jar target/todo-app-0.0.1-SNAPSHOT.jar
Open http://localhost:8080. Same app, running from the packaged artifact.
Stop the app (Ctrl+C) and the database:
docker compose -f compose.yaml down
2. Run the whole stack in Docker
The JAR is portable, but in production you usually want the app itself containerized. The generated Dockerfile builds a lean image; docker-compose.yml wires it to the database.
Build and start
# Build the app image
docker compose -f docker-compose.yml build
# Start everything (app + postgres)
docker compose -f docker-compose.yml up -d
# Check logs
docker compose -f docker-compose.yml logs -f spring-app
Once "Started Application" appears, open http://localhost:8080. This time there's no Java or Maven on your host — everything runs in containers.
Stop it:
docker compose -f docker-compose.yml down
Understanding the Dockerfile
Open Dockerfile. It uses a multi-stage build with two distinct stages.
Stage 1 — Build
FROM eclipse-temurin:25-jdk-noble AS build
Version note: the
25tag matches the Java version inversions.json. Dr JSkill writes this value into the generated Dockerfile automatically — you don't need to update it by hand.
A full JDK image is used here so Maven can compile your code and the Maven Frontend Plugin can download Node and build the Vite bundle. This image is large (~500 MB) but it's never shipped — it's only used during build.
The dependency download step is its own layer:
COPY pom.xml .
RUN ./mvnw dependency:go-offline
Docker caches this layer as long as pom.xml doesn't change. On subsequent builds only the COPY src → RUN ./mvnw package step reruns, keeping iterative builds fast.
After packaging, the build stage does two more things that keep the final image small and fast:
- Explodes the Spring Boot jar into layers (
java -Djarmode=tools -jar app.jar extract --layers) so dependencies, the loader, and your own classes land in separate image layers — the dependency layer (which rarely changes) stays cached across rebuilds. - Builds a custom Java runtime with
jlink, containing only the JDK modules the app actually needs instead of a full JRE. That trimmed runtime is what gets copied into the final image.
Stage 2 — Runtime
FROM gcr.io/distroless/base-debian12:nonroot
The runtime base is a Google distroless image — glibc and little else: no shell, no package manager, no curl. Combined with the jlink runtime and the exploded layers, the final image for this full-stack app lands around ~267 MB with a very small attack surface. The :nonroot tag runs the app as an unprivileged user (uid 65532).
Roughly, that breaks down as ~70 MB of jlink'd Java runtime, ~55 MB of application dependencies, and ~130 MB of distroless base (glibc, OpenSSL, CA certificates). A backend-only app with fewer dependencies lands lower; the jlink runtime and base are the floor you cannot trim much further without going native.
Because there's no shell or curl in the image, there is no Docker HEALTHCHECK. You probe /actuator/health from your orchestrator's liveness/readiness checks instead (Kubernetes, Azure Container Apps, etc. do this for you).
JVM tuning is passed through JAVA_TOOL_OPTIONS rather than the ENTRYPOINT, so the flags apply however the JVM is launched:
ENV JAVA_TOOL_OPTIONS="-XX:+UseContainerSupport -XX:MaxRAMPercentage=75.0 -XX:+UseG1GC ..."
ENTRYPOINT ["/opt/java/bin/java", "org.springframework.boot.loader.launch.JarLauncher"]
-XX:+UseContainerSupport— makes the JVM read CPU and memory limits from the container's cgroup rather than the host machine's physical resources. Without this, a small container on a 32 GB host would size its heap from 32 GB.-XX:MaxRAMPercentage=75.0— caps heap at 75% of the container's memory limit, leaving room for non-heap memory (metaspace, threads, code cache, native memory).
The JarLauncher entrypoint boots the app straight from the exploded layers — there's no fat app.jar to unpack at startup.
Dr JSkill also generates
Dockerfile-aot(JVM + Spring AOT) andDockerfile-crac(Coordinated Restore at Checkpoint) for even faster startup, alongsideDockerfile-nativebelow. Seereferences/DOCKER.mdfor when to use each.
Inspect the image
Compose tags its image after the project directory (todo-app-spring-app), so build it under an explicit name first if you want to inspect it directly:
docker build -t todo-app:latest .
docker images todo-app:latest
# todo-app latest ... 267MB
# The image is distroless, so inspect its config instead of `docker exec`-ing a shell:
docker inspect todo-app:latest --format '{{.Config.Entrypoint}}'
docker inspect todo-app:latest --format '{{.Config.Env}}'
Build fails downloading Node or npm with an SSL/TLS handshake error? You are almost certainly behind a corporate proxy or VPN: your host can reach the npm registry but the container cannot. See Appendix B → "Docker build fails downloading Node/npm".
No shell in the image? That's deliberate. To debug a running distroless container, attach a temporary sidecar that shares its process namespace — see "Debugging a distroless image" in
references/DOCKER.md.
See references/DOCKER.md for a deeper dive into all four image variants, layer caching, and multi-arch builds.
3. GraalVM native image
A GraalVM native image ahead-of-time compiles your app to a standalone binary. Key tradeoffs:
| JVM (Docker) | Native (Docker) | |
|---|---|---|
| Startup | 2–10 s | 50–200 ms |
| Idle memory | 300 MB–1 GB | 50–150 MB |
| Image size | ~267 MB (measured) | varies — measure it |
| Peak throughput | ✅ JIT-optimized | ⚠️ No JIT |
| Build time | ~1 min | 5–15 min |
Native is ideal for serverless, scale-to-zero, and short-lived workloads. Long-running services under sustained load usually benefit more from JIT — pick the right tool for the job.
On image size: both Dockerfiles ship on the same
gcr.io/distroless/base-debian12:nonrootbase (43.5 MB measured), so that part is a shared floor. Native replaces the ~70 MB jlink runtime and ~55 MB of dependency jars with a single self-contained binary whose size tracks how much of your dependency graph is actually reachable. For this full-stack app that trade came out roughly even — the native image measured slightly larger than the JVM one (see Compare image sizes below). Native's dependable wins are startup and memory, not uncompressed image size. Measure your own withdocker imagesrather than assuming.
Build and run
Dr JSkill generates Dockerfile-native and docker-compose-native.yml so you can try native with zero local setup — no GraalVM installation needed:
docker compose -f docker-compose-native.yml up --build
The first build takes a while (GraalVM is compiling your whole app and every dependency into native machine code). Grab a coffee — this can take 5–15 minutes depending on your machine.
Once up, open http://localhost:8080. The application is identical; the difference is in how fast it comes up after a restart.
Compare startup times
With both stacks available, restart each one and time the startup log message:
# JVM version — time from start to "Started Application"
docker compose -f docker-compose.yml restart spring-app
docker compose -f docker-compose.yml logs --since 1m spring-app | grep "Started"
# Native version — same measurement
docker compose -f docker-compose-native.yml restart spring-app-native
docker compose -f docker-compose-native.yml logs --since 1m spring-app-native | grep "Started"
Typical output on a laptop:
# JVM
Started TodoApplication in 3.812 seconds
# Native
Started TodoApplication in 0.087 seconds
Compare image sizes
docker images | grep -E "todo-app|REPOSITORY"
# REPOSITORY TAG SIZE
# todo-app latest 267MB ← JVM (jlink + distroless)
# todo-app-native latest 276MB ← native
Surprised the native image is not dramatically smaller? Your numbers will differ, and that is the point: the JVM image here is already jlink-trimmed and distroless, so there is little fat left for native to cut. Native's dependable wins are startup time (fractions of a second, above) and compressed size — the same two images pull as roughly 68 MB (native) versus 103 MB (JVM). docker images reports uncompressed size, which is not what you push or pull.
Stop native
docker compose -f docker-compose-native.yml down
See references/GRAALVM.md for build requirements, reflection hints, native Maven configuration, and troubleshooting the most common failure modes.
4. Production config — the one property you must change
Development uses spring.jpa.hibernate.ddl-auto=update so the schema evolves with your entities. In production, set it to validate:
# application-prod.properties (or via environment variable)
spring.jpa.hibernate.ddl-auto=validate
With validate, Hibernate only checks that the schema matches your entities on startup and fails fast if it doesn't. Schema changes are then a deliberate step, not a side effect of a deploy.
In Copilot CLI:
Create an application-prod.properties with production-safe settings:
ddl-auto=validate, actuator endpoints restricted to health/info, SQL
logging disabled. Leave other settings to their defaults.
Review and commit.
5. A word on secrets
You've been running with hardcoded user / password for Postgres. That's fine for development but not for anything else.
- Never commit passwords.
.gitignorealready excludes.env, but double-check before pushing. - Use environment variables in production (the generated properties already read from
${SPRING_DATASOURCE_PASSWORD:...}). - Use your platform's secret store: Azure Key Vault, AWS Secrets Manager, HashiCorp Vault, Kubernetes Secrets (sealed), etc.
See references/CONFIGURATION.md and references/SECURITY.md.
6. (Optional) Deploy to Azure Container Apps
Prerequisites:
- The project is published to a GitHub repository — images will be pushed to
ghcr.io/<owner>/<repo>and the CI/CD / OIDC pieces in AZURE.md pin an exactrepo:<owner>/<repo>:ref:refs/heads/mainsubject. If you skipped Chapter 2 § 10 — Publish to GitHub, do it now before continuing.- An Azure account with an active subscription (free trial works)
- Azure CLI ≥ 2.85 installed and logged in (
az login)jqinstalled (brew install jq/apt install jq/winget install jqlang.jq)This section creates billable Azure resources. The cheapest configuration costs roughly $0.02–0.05/hour while running. Delete the resource group afterwards to stop all charges.
You have a working Docker image. The skill's reference file references/AZURE.md contains a complete, production-grade deployment recipe for Azure Container Apps — HTTPS by default, scale-to-zero, rolling deployments, and optional VNET-injected PostgreSQL with the DB password stored as a Container Apps secret.
Rather than copy-pasting every command by hand, let the agent drive it. Start with the quick start (app only, no database) to get a public URL in a few minutes, then add PostgreSQL if you want persistence.
Quick start — app only
Use Dr JSkill to deploy this app to Azure Container Apps via the
Quick Start path (no database).
- Ask me for RESOURCE_GROUP, LOCATION, and APP_NAME before creating anything.
- Walk me through each step and confirm before running any `az` command that
creates or modifies Azure resources.
- At the end, print the public URL.
Once the app is up, the agent will print the public URL. Verify it:
# Replace <YOUR_APP_FQDN> with the URL the agent printed
curl https://<YOUR_APP_FQDN>/actuator/health
# {"status":"UP"}
Open the URL in a browser — your Todo app is live with TLS managed by Azure.
Add a PostgreSQL database
Use Dr JSkill to add a managed PostgreSQL database to my existing Azure
deployment, via the VNET-injected path.
Use the RESOURCE_GROUP, LOCATION, APP_NAME, CONTAINER_APP_ENV, and
CONTAINER_APP_NAME values from the previous deployment. Walk me through one
section at a time and confirm before running anything destructive.
The agent will create a VNET, a private PostgreSQL Flexible Server, and store the database password as a Container Apps secret — no credentials committed to source code.
Deploy the native image instead
Use Dr JSkill to redeploy my Azure Container App using the GraalVM native
image (Dockerfile-native) instead of the JVM image.
After redeploying, set --cpu 0.25 --memory 0.5Gi and remove JAVA_TOOL_OPTIONS
(there is no JVM heap to configure).
Clean up
az group delete --name "$RESOURCE_GROUP" --yes --no-wait
This removes the resource group and everything inside it. Takes a few minutes in the background.
Try this yourself
- Run the native version next to the JVM version (different ports) and time
curlagainst both. Compare first-response latency after a fresh start. - Push your image to Docker Hub (or GitHub Container Registry) and pull it on another machine.
- Ask the agent: "Set up CI/CD using GitHub Actions and OIDC so every push to main rebuilds and redeploys the Container App — no secrets stored in the repo." (See
references/AZURE.md.)
Checkpoint
./mvnw clean packageproduces a runnable JARdocker compose -f docker-compose.yml upruns the full stack in containers- You understand the two-stage Dockerfile and why each JVM flag is there
- You've at least attempted the native build and observed the startup time difference
git log --onelineshows yourapplication-prod.propertiescommit- (Optional) Your app is live on a public Azure URL
Next → Chapter 9 — Going further