Dr JSkill
Workshop
Ecosystem
GitHub
Workshop
Ecosystem
GitHub
  • Workshop

    • GitHub Copilot CLI + Java workshop, using Dr JSkill
    • 00 — Introduction
    • 01 — Setup
    • 02 — Getting started
    • 03 — Anatomy of the generated application
    • 04 — Adding users
    • 05 — A more professional front-end
    • 06 — Testing
    • 07 — Performance
    • 08 — Deployment
    • 09 — Going further
    • Appendix A — Prompt cheat sheet
    • Appendix B — Troubleshooting

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 25 tag matches the Java version in versions.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) and Dockerfile-crac (Coordinated Restore at Checkpoint) for even faster startup, alongside Dockerfile-native below. See references/DOCKER.md for 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)
Startup2–10 s50–200 ms
Idle memory300 MB–1 GB50–150 MB
Image size~267 MB (measured)varies — measure it
Peak throughput✅ JIT-optimized⚠️ No JIT
Build time~1 min5–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:nonroot base (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 with docker images rather 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. .gitignore already 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 exact repo:<owner>/<repo>:ref:refs/heads/main subject. 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)
  • jq installed (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 curl against 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 package produces a runnable JAR
  • docker compose -f docker-compose.yml up runs 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 --oneline shows your application-prod.properties commit
  • (Optional) Your app is live on a public Azure URL

Next → Chapter 9 — Going further

Edit this page
Last Updated: 9/9/26, 6:24 PM
Prev
07 — Performance
Next
09 — Going further