Lesson 40 • Advanced
Deployment & Packaging
Code is only useful once it's running in production. In this lesson you'll package a Java app into one runnable JAR, wrap it in a small, secure Docker image, configure it per environment, give it a health check, and hand it to an orchestrator with the right JVM flags.
What You'll Learn in This Lesson
- ✓Build a fat/uber JAR with the Maven Shade plugin (and Spring Boot)
- ✓Dockerize a Java app with a multi-stage, JRE-based image
- ✓Use layered jars so code changes rebuild in seconds, not minutes
- ✓Configure per-environment settings with Spring profiles and env vars
- ✓Add health checks and Actuator probes for orchestrators
- ✓Set container-aware JVM heap flags and a CI/CD pipeline
Before You Start
You should be comfortable with Maven & Gradle (the build tools that produce your JARs) and Modular Java. Basic command-line familiarity is assumed. The examples here are read-only — copy them into a real project and run the build/Docker commands on your own machine.
Deployment in Plain English (An Analogy)
Think of shipping a meal to a customer. The fat JAR is the finished dish with every ingredient already inside — no shopping required at the destination. The Docker image is the sealed, labelled takeaway box that looks identical in every kitchen, so "it works on my machine" stops being an excuse. Environment config is the order ticket — same dish, but this one is "no chilli, table 4". The health check is the waiter glancing at the table to confirm the food actually arrived hot. And CI/CD is the conveyor belt that cooks, plates, checks, and sends every order automatically the moment it's placed.
💡 Key idea: You package once and configure per environment. The same image runs in dev, staging, and prod — only the environment variables and active profile change. Nothing about the running database or its password is ever baked into the artifact.
1️⃣ Build One Runnable JAR (the Fat/Uber JAR)
A plain mvn package jar contains only your classes. Run it and you get ClassNotFoundException the moment it reaches a library, because the dependencies aren't inside. A fat JAR (also called an uber JAR) bundles your code and every dependency into a single file, so any machine with Java can run it with java -jar app.jar.
If you use Spring Boot, the spring-boot-maven-plugin builds an executable fat jar automatically — mvn package just works. For a plain project, use the Maven Shade plugin to merge everything and write the Main-Class into the manifest, as below.
<!-- pom.xml — make Maven produce ONE runnable "fat" (uber) JAR -->
<!-- A fat JAR bundles your code AND every dependency into a single file, -->
<!-- so the server only needs Java installed — nothing else to download. -->
<project>
<groupId>com.example</groupId>
<artifactId>myapp</artifactId>
<version>1.0.0</version>
<packaging>jar</packaging>
<build>
<finalName>app</finalName> <!-- output is target/app.jar -->
<plugins>
<plugin>
<!-- Shade merges all dependency jars into one -->
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-shade-plugin</artifactId>
<version>3.6.0</version>
<executions>
<execution>
<phase>package</phase> <!-- runs on 'mvn package' -->
<goals><goal>shade</goal></goals>
<configuration>
<transformers>
<!-- write Main-Class into the JAR manifest so -->
<!-- 'java -jar app.jar' knows where to start -->
<transformer
implementation="org.apache.maven.plugins.shade.resource.ManifestResourceTransformer">
<mainClass>com.example.App</mainClass>
</transformer>
</transformers>
</configuration>
</execution>
</executions>
</plugin>
</plugins>
</build>
</project>$ mvn clean package -DskipTests
[INFO] --- shade:3.6.0:shade (default) @ myapp ---
[INFO] Including com.fasterxml.jackson.core:jackson-databind:jar in the shaded jar.
[INFO] Replacing original artifact with shaded artifact.
[INFO] BUILD SUCCESS
$ ls -lh target/app.jar
-rw-r--r-- 1 dev dev 14M target/app.jar # one file, all deps inside
$ java -jar target/app.jar
Server started on http://0.0.0.0:8080Main-Class in the manifest, java -jar app.jar fails with "no main manifest attribute". The Shade transformer (or Spring Boot's plugin) writes it for you.2️⃣ Dockerize It (Multi-Stage, JRE Base)
A Docker image is a self-contained snapshot of your app plus exactly the runtime it needs, so it behaves the same on your laptop and in the cloud. The trap is bundling the JDK (compiler) and Maven into the shipped image — that's ~750MB of tools production never uses.
The fix is a multi-stage build: compile in a throwaway build stage, then copy only the finished jar into a tiny JRE-only base (a JRE runs Java but can't compile it). The build stage is discarded, so its size never ships.
# Multi-stage Dockerfile. Stage 1 has the full JDK + Maven and is
# THROWN AWAY after the build, so its size never ships. Stage 2 is a
# tiny JRE-only image that becomes your final, deployable artifact.
# ---- Stage 1: BUILD (heavy, discarded) ----
FROM maven:3.9-eclipse-temurin-21 AS build
WORKDIR /app
COPY pom.xml .
RUN mvn dependency:go-offline -B # cached in its own layer
COPY src ./src
RUN mvn clean package -DskipTests # produces target/app.jar
# ---- Stage 2: RUNTIME (small, shipped) ----
FROM eclipse-temurin:21-jre-alpine # JRE only, no compiler
WORKDIR /app
# Run as a non-root user — never run a container as root
RUN addgroup -S app && adduser -S app -G app
USER app
COPY --from=build /app/target/app.jar app.jar
EXPOSE 8080
# Container-aware JVM flags + a health probe (see sections below)
HEALTHCHECK --interval=30s --timeout=3s CMD wget -qO- http://localhost:8080/actuator/health || exit 1
ENTRYPOINT ["java", "-XX:MaxRAMPercentage=75", "-jar", "app.jar"]$ docker build -t myapp:1.0.0 .
=> [build 5/5] RUN mvn clean package -DskipTests 38.4s
=> [stage-1 4/4] COPY --from=build /app/target/app.jar 0.1s
=> exporting to image
=> => naming to docker.io/library/myapp:1.0.0
$ docker images myapp
REPOSITORY TAG SIZE
myapp 1.0.0 182MB # JRE-alpine final image (build stage discarded)
$ docker run -p 8080:8080 myapp:1.0.0
Server started on http://0.0.0.0:8080Notice the ordering: COPY pom.xml and go-offline come before COPY src. Docker caches each step, so as long as your dependencies don't change, rebuilds skip the slow download and only recompile your code.
3️⃣ Layered Jars — Rebuild in Seconds
A fat jar is one big blob, so changing a single line of code invalidates the whole Docker layer and re-copies hundreds of megabytes of dependencies. Spring Boot's layered jars split that blob by how often each part changes: dependencies and the loader almost never change, your application code changes every commit.
You copy the least-changed layers first and your code last. Docker caches the unchanged layers, so an ordinary code change rebuilds only the small final layer.
# Spring Boot can split the fat JAR into LAYERS by how often they
# change. Dependencies (rarely change) cache separately from your
# code (changes every commit), so rebuilds only re-copy the last layer.
# ---- Build stage ----
FROM eclipse-temurin:21-jdk-alpine AS build
WORKDIR /app
COPY . .
RUN ./mvnw clean package -DskipTests
# Explode the fat JAR into its layer folders
RUN java -Djarmode=layertools -jar target/app.jar extract
# ---- Runtime stage: copy layers least-changed FIRST ----
FROM eclipse-temurin:21-jre-alpine
WORKDIR /app
COPY --from=build /app/dependencies/ ./
COPY --from=build /app/spring-boot-loader/ ./
COPY --from=build /app/snapshot-dependencies/ ./
COPY --from=build /app/application/ ./ # your code — the only layer that changes daily
EXPOSE 8080
ENTRYPOINT ["java", "org.springframework.boot.loader.launch.JarLauncher"]$ java -Djarmode=layertools -jar target/app.jar list
dependencies
spring-boot-loader
snapshot-dependencies
application
# First build: all layers built. Change ONE line of Java and rebuild:
$ docker build -t myapp:1.0.1 .
=> CACHED [stage-1 3/6] COPY --from=build /app/dependencies/ ./ # reused!
=> CACHED [stage-1 4/6] COPY --from=build /app/spring-boot-loader/ ./
=> [stage-1 6/6] COPY --from=build /app/application/ ./ 0.1s # only this rebuilt
=> exporting to image 1.2s # seconds, not minutes🎯 Your Turn #1 — Finish the Dockerfile
Fill in the four blanks so this becomes a correct, small, multi-stage image. Check your answer against the expected note at the bottom of the block.
# 🎯 YOUR TURN — finish this multi-stage Dockerfile.
# Fill in the blanks marked with ___ (instructions on the // 👉 lines).
# ---- Build stage ----
FROM maven:3.9-eclipse-temurin-21 AS build
WORKDIR /app
COPY . .
RUN mvn clean package -DskipTests # makes target/app.jar
# ---- Runtime stage ----
# 👉 1) Use a small JRE-only base, NOT the full JDK (saves ~400MB)
FROM ___
WORKDIR /app
# 👉 2) Copy the jar FROM the build stage (use --from=build)
COPY ___ app.jar
# 👉 3) Tell the container which port the app listens on
___ 8080
# 👉 4) Start the app with a container-aware heap flag
ENTRYPOINT ["java", "___", "-jar", "app.jar"]
# ✅ Expected: 'docker build .' succeeds and the final image is a small
# JRE image (~180MB), not an ~750MB JDK+Maven image.4️⃣ Configure Per Environment (Profiles & Env Vars)
Dev, staging, and prod need different databases, log levels, and secrets — but you should ship one artifact, not rebuild per environment. Spring profiles solve this: each environment gets an application-<profile>.properties file, and you pick one at launch with SPRING_PROFILES_ACTIVE.
Secrets — passwords, API keys — must never be hardcoded in the jar or the image. Reference them with ${ENV_VAR} placeholders and inject the real values from the environment or a secret manager at runtime.
# Each environment gets its own application-<profile>.properties.
# You ship ONE jar; the environment chooses the profile at runtime.
# NEVER hardcode the URL/password into the jar.
# src/main/resources/application.properties (shared defaults)
server.port=8080
server.shutdown=graceful # finish in-flight requests on stop
# src/main/resources/application-dev.properties
spring.datasource.url=jdbc:h2:mem:devdb
logging.level.root=DEBUG
# src/main/resources/application-prod.properties
spring.datasource.url=${DB_URL} # read from environment variables
spring.datasource.username=${DB_USER}
spring.datasource.password=${DB_PASSWORD}
logging.level.root=WARN
# Pick the profile + inject secrets via the environment at launch:
export DB_URL="jdbc:postgresql://prod-db:5432/app"
export DB_USER="app"
export DB_PASSWORD="s3cr3t-from-vault"
SPRING_PROFILES_ACTIVE=prod java -jar app.jar$ SPRING_PROFILES_ACTIVE=prod java -jar app.jar
:: Spring Boot :: (v3.3.0)
The following 1 profile is active: "prod"
HikariPool-1 - Added connection org.postgresql.jdbc.PgConnection@1a2b
Tomcat started on port 8080 (http)
Started App in 2.41 seconds (process running for 2.9)5️⃣ Health Checks & Actuator
An orchestrator can only restart a hung container or stop routing traffic to a broken one if the app tells it how it's doing. Spring Boot Actuator exposes that signal at /actuator/health. Kubernetes uses two probes: liveness (is it hung? restart it) and readiness (can it take traffic yet?).
A good health check verifies real downstream dependencies — like the database — instead of always returning "UP". The custom HealthIndicator below does exactly that.
// Add the dependency to pom.xml:
// <dependency>
// <groupId>org.springframework.boot</groupId>
// <artifactId>spring-boot-starter-actuator</artifactId>
// </dependency>
//
// application.properties — expose only what you need:
// management.endpoints.web.exposure.include=health,info,metrics,prometheus
// management.endpoint.health.show-details=when_authorized
// management.endpoint.health.probes.enabled=true # readiness + liveness
// server.shutdown=graceful # finish in-flight requests
// spring.lifecycle.timeout-per-shutdown-phase=30s
// A custom HealthIndicator — checks the database, not just "UP".
import org.springframework.boot.actuate.health.Health;
import org.springframework.boot.actuate.health.HealthIndicator;
import org.springframework.stereotype.Component;
import javax.sql.DataSource;
import java.sql.Connection;
@Component
public class DatabaseHealthIndicator implements HealthIndicator {
private final DataSource dataSource;
public DatabaseHealthIndicator(DataSource dataSource) {
this.dataSource = dataSource;
}
@Override
public Health health() {
try (Connection c = dataSource.getConnection()) {
if (c.isValid(2)) { // 2-second timeout
return Health.up()
.withDetail("database", c.getMetaData().getDatabaseProductName())
.build();
}
return Health.down().withDetail("reason", "connection not valid").build();
} catch (Exception e) {
return Health.down(e).build(); // reports DOWN with the error
}
}
}$ curl http://localhost:8080/actuator/health
{ "status": "UP",
"components": {
"db": { "status": "UP", "details": { "database": "PostgreSQL" } },
"diskSpace": { "status": "UP", "details": { "free": 320000000000 } }
} }
$ curl http://localhost:8080/actuator/health/readiness
{ "status": "UP" } # k8s only sends traffic once this is UP🎯 Your Turn #2 — Health & Config
Fill in the blanks to expose a health endpoint, enable Kubernetes probes, shut down gracefully, and launch with the prod profile.
# 🎯 YOUR TURN — wire up health checks + per-environment config.
# Fill in the blanks marked with ___.
# application.properties — expose the health endpoint and shut down cleanly:
# 👉 1) Expose health + info + metrics over HTTP (comma-separated)
management.endpoints.web.exposure.include=___
# 👉 2) Turn on the separate liveness + readiness probes for Kubernetes
management.endpoint.health.probes.enabled=___
# 👉 3) Drain in-flight requests instead of dropping them on shutdown
server.shutdown=___
# Launch with the production profile and read the DB password from the env,
# never from the jar:
# 👉 4) Set the active profile to "prod" before 'java -jar app.jar'
___=prod java -jar app.jar
# ✅ Expected: GET http://localhost:8080/actuator/health returns
# {"status":"UP"} and /actuator/health/readiness exists.6️⃣ JVM Flags for Containers (Get the Heap Right)
The single most common reason a Java container dies in production is the heap. Older JVMs sized the heap from the host machine's RAM, ignoring the container's limit — so a 512MB-capped container on a big host would try to grab gigabytes and get killed by the kernel (OOMKilled, exit 137).
Modern JVMs (Java 11+) are container-aware. Instead of a fixed -Xmx you must keep in sync with the deployment, size the heap as a percentage of the container limit with -XX:MaxRAMPercentage=75. Leave headroom (the other 25%) for metaspace, thread stacks, and off-heap buffers.
// Why "wrong heap in a container" crashes apps, and how to fix it.
//
// THE TRAP: old JVMs read the HOST machine's memory, not the
// container's limit. A container capped at 512m on a 64 GB host can
// have the JVM size its heap for 64 GB -> the OS kills it (OOMKilled).
//
// THE FIX: modern JVMs (11+) are container-aware. Size the heap as a
// PERCENTAGE of the container limit, not a fixed -Xmx that you must
// keep in sync with the deployment:
// In the Dockerfile / k8s command:
// java -XX:MaxRAMPercentage=75 -jar app.jar
//
// With a container limited to 512Mi, that gives ~384Mi of heap and
// leaves headroom for metaspace, thread stacks, and off-heap buffers.
// You can confirm what the JVM actually sees inside the container:
// $ java -XX:+PrintFlagsFinal -version | grep -i maxheap
// size_t MaxHeapSize = 402653184 // ~384 MiB, matches the limit
public class HeapInfo {
public static void main(String[] args) {
long maxMb = Runtime.getRuntime().maxMemory() / (1024 * 1024);
int cpus = Runtime.getRuntime().availableProcessors();
System.out.println("JVM max heap : " + maxMb + " MB");
System.out.println("CPUs visible : " + cpus);
}
}# Run inside a memory-limited container:
$ docker run --memory=512m myapp java -XX:MaxRAMPercentage=75 HeapInfo
JVM max heap : 384 MB # sized to the container, not the 64GB host
CPUs visible : 2 # respects --cpus too7️⃣ Ship It Automatically (CI/CD Note)
Doing all of the above by hand on every release is slow and error-prone. CI/CD is the assembly line: every push compiles, tests, builds the image, and deploys it — no manual steps, no "works on my machine". The pipeline below is a complete GitHub Actions workflow that does exactly that.
Tag each image with the git commit SHA: it makes every deploy traceable and rollback trivial — just re-deploy the previous SHA.
# .github/workflows/ci.yml — build, test, image, deploy on every push to main.
name: Java CI/CD
on:
push:
branches: [main]
pull_request:
jobs:
build-and-deploy:
runs-on: ubuntu-latest
permissions:
contents: read
packages: write
steps:
- uses: actions/checkout@v4
- uses: actions/setup-java@v4
with:
java-version: '21'
distribution: 'temurin'
cache: maven
- name: Compile, test, package
run: mvn -B clean verify # fails the build if any test fails
- name: Log in to the container registry
uses: docker/login-action@v3
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Build & push image (tagged with the git SHA)
run: |
IMAGE=ghcr.io/${{ github.repository }}/myapp
docker build -t $IMAGE:${{ github.sha }} -t $IMAGE:latest .
docker push $IMAGE:${{ github.sha }}
docker push $IMAGE:latest
- name: Deploy to staging (main only)
if: github.ref == 'refs/heads/main'
run: |
kubectl set image deploy/myapp \
myapp=ghcr.io/${{ github.repository }}/myapp:${{ github.sha }} \
--namespace=stagingRun mvn -B clean verify
[INFO] Tests run: 42, Failures: 0, Errors: 0, Skipped: 0
[INFO] BUILD SUCCESS
Run docker build ...
=> pushing ghcr.io/acme/myapp:9f3c1a2 ... done
Run kubectl set image ...
deployment.apps/myapp image updated
✓ Job completed in 1m 58sCommon Errors (and the Fix)
- ❌ Fat (huge) image: a single-stage Dockerfile ships the JDK + Maven (~750MB). Fix: use a multi-stage build and a
21-jre-alpineruntime base; only the jar is copied across (~180MB). For even faster rebuilds, use layered jars. - ❌ No health check: the orchestrator can't tell a hung container from a healthy one, so crashed services never restart and traffic still hits them. Fix: add
spring-boot-starter-actuator, expose/actuator/health, enable probes, and add aHEALTHCHECKin the Dockerfile. - ❌ Hardcoded config / secrets: a baked-in
jdbc:postgresql://prod-dbor password means rebuilding per environment and leaking secrets into image history. Fix: use Spring profiles plus${ENV_VAR}placeholders, and inject real values from env vars or a secret manager. - ❌ Wrong heap in a container (OOMKilled, exit 137): the JVM sizes its heap from the host's RAM and exceeds the container limit. Fix: run a Java 11+ JVM with
-XX:MaxRAMPercentage=75so the heap scales with the container's memory limit.
🧩 Mini-Challenge — Production-Ready Container
Support is gone now — only the outline remains. Write the full Dockerfile and run command yourself, then check it against the expected result in the comment.
# 🎯 MINI-CHALLENGE: containerise a Spring Boot service for production.
# Write the Dockerfile + run command yourself — only the outline is given.
#
# 1. Multi-stage build:
# - Build stage: maven:3.9-eclipse-temurin-21, run 'mvn package -DskipTests'
# - Runtime stage: eclipse-temurin:21-jre-alpine
# 2. Create and switch to a NON-root user in the runtime stage
# 3. COPY --from=build the jar, EXPOSE 8080
# 4. Add a HEALTHCHECK that hits /actuator/health
# 5. ENTRYPOINT runs java with -XX:MaxRAMPercentage=75
# 6. Run it limited to 512MB of memory and pass SPRING_PROFILES_ACTIVE=prod
#
# ✅ Expected: 'docker run --memory=512m -e SPRING_PROFILES_ACTIVE=prod
# -p 8080:8080 myapp' starts, 'docker ps' shows STATUS ... (healthy),
# and curl localhost:8080/actuator/health returns {"status":"UP"}.
# your Dockerfile + docker run command here📋 Quick Reference
| Task | Command / Setting | Notes |
|---|---|---|
| Build fat JAR | mvn clean package | Shade / Spring Boot plugin |
| Run JAR | java -jar app.jar | Needs Main-Class in manifest |
| Build image | docker build -t app:v1 . | Multi-stage, JRE base |
| Extract layers | java -Djarmode=layertools -jar app.jar extract | Faster rebuilds |
| Pick environment | SPRING_PROFILES_ACTIVE=prod | One jar, many envs |
| Container heap | -XX:MaxRAMPercentage=75 | Avoids OOMKilled |
| Health endpoint | /actuator/health | Liveness + readiness probes |
| Graceful stop | server.shutdown=graceful | Drains in-flight requests |
Frequently Asked Questions
What is a fat JAR (uber JAR) and why do I need one?
A fat JAR — also called an uber JAR — bundles your compiled classes AND every dependency into a single runnable .jar file. A plain 'mvn package' jar contains only your code, so it crashes with ClassNotFoundException unless the dependencies are on the classpath. A fat JAR has everything inside, so any machine with just Java installed can run it with 'java -jar app.jar'. Spring Boot's spring-boot-maven-plugin builds one automatically; for plain projects, use the Maven Shade plugin (or Gradle's Shadow plugin) and set the Main-Class in the manifest.
Why use a multi-stage Dockerfile instead of one stage?
A single stage that compiles AND runs the app must keep the JDK and Maven in the final image, which pushes it to roughly 750MB. A multi-stage build does the compile in a throwaway 'build' stage and then copies only the finished jar into a tiny JRE-only runtime stage, so the shipped image is around 180MB. Smaller images pull faster, start faster, and have a smaller attack surface. The build tools never reach production.
What are layered jars and how do they speed up Docker builds?
Spring Boot can split a fat jar into layers ordered by how often they change: dependencies and the loader rarely change, while your application code changes every commit. In the Dockerfile you COPY the least-changed layers first and your code last. Because Docker caches unchanged layers, a normal code change only rebuilds the final, small application layer instead of re-copying every dependency — turning minute-long rebuilds into seconds. Extract them with 'java -Djarmode=layertools -jar app.jar extract'.
Why does my Java container get OOMKilled even though -Xmx looks fine?
On older JVMs the heap is sized from the HOST machine's RAM, ignoring the container's memory limit. A container capped at 512MB on a 64GB host can have the JVM try to use far more than 512MB, so the kernel kills it (OOMKilled, exit code 137). The fix is to let a container-aware JVM (Java 11+) size the heap relative to the cgroup limit: java -XX:MaxRAMPercentage=75 -jar app.jar. That uses ~75% of the container limit for heap and leaves room for metaspace, thread stacks, and off-heap buffers.
How should I manage configuration and secrets across environments?
Ship ONE jar and change behaviour with configuration, not by rebuilding. Spring profiles (application-dev.properties, application-prod.properties) hold per-environment settings, selected at runtime with SPRING_PROFILES_ACTIVE=prod. Never bake passwords or connection strings into the jar or the Dockerfile — inject them as environment variables from a secret manager (Kubernetes Secrets, Vault, AWS Secrets Manager) and reference them in properties with ${DB_PASSWORD} placeholders.
What does Spring Boot Actuator add for deployment?
Actuator exposes operational HTTP endpoints — most importantly /actuator/health for health checks, plus /actuator/metrics and /actuator/prometheus for monitoring. Orchestrators like Kubernetes call /actuator/health/liveness to decide whether to restart a hung container and /actuator/health/readiness to decide whether to send it traffic. Add the spring-boot-starter-actuator dependency, expose only the endpoints you need, and your Dockerfile HEALTHCHECK and the platform's probes can both reach a real, dependency-aware health signal.
🎉 Lesson Complete!
You can now take a Java app the whole way to production: build one runnable fat JAR, wrap it in a small multi-stage Docker image with layered caching, configure it per environment with profiles and env vars, expose a real health check, size the heap for the container, and ship it through a CI/CD pipeline.
Next up: Logging — professional, structured logging with SLF4J and Logback that aggregates cleanly in production.
Sign up for free to track which lessons you've completed and get learning reminders.