Deployment

Reviewed & published by Brayan K

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.

Part of the free Java course at LearnCodingFast — hands-on lessons with worked examples and the output they print, plus practice exercises and a quick quiz.

What You'll Learn in This Lesson

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>

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"]

Notice 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"]

🎯 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

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
        }
    }
}

🎯 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);
    }
}

7️⃣ 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.

Common Errors (and the Fix)

🧩 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

TaskCommand / SettingNotes
Build fat JARmvn clean packageShade / Spring Boot plugin
Run JARjava -jar app.jarNeeds Main-Class in manifest
Build imagedocker build -t app:v1 .Multi-stage, JRE base
Extract layersjava -Djarmode=layertools -jar app.jar extractFaster rebuilds
Pick environmentSPRING_PROFILES_ACTIVE=prodOne jar, many envs
Container heap-XX:MaxRAMPercentage=75Avoids OOMKilled
Health endpoint/actuator/healthLiveness + readiness probes
Graceful stopserver.shutdown=gracefulDrains in-flight requests

🎉 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.

Practice quiz

What is a fat (uber) JAR?

  • A JAR that only contains your compiled classes
  • A compressed JAR with no manifest
  • A single JAR bundling your code AND every dependency, runnable with java -jar
  • A JAR that requires Maven to run

Answer: A single JAR bundling your code AND every dependency, runnable with java -jar. A fat/uber JAR bundles your classes and all dependencies into one file, so any machine with Java can run it via java -jar app.jar.

Without which manifest entry does 'java -jar app.jar' fail with 'no main manifest attribute'?

  • Main-Class
  • Class-Path
  • Implementation-Version
  • Built-By

Answer: Main-Class. The manifest must declare a Main-Class so java -jar knows where to start; the Shade transformer or Spring Boot plugin writes it.

Why use a multi-stage Dockerfile for a Java app?

  • To run two apps at once
  • To require root in production
  • To avoid writing a manifest
  • To compile in a throwaway stage and ship only the jar in a tiny JRE image

Answer: To compile in a throwaway stage and ship only the jar in a tiny JRE image. A multi-stage build compiles in a discarded build stage and copies only the finished jar into a small JRE-only runtime image (~180MB vs ~750MB).

How do Spring Boot layered jars speed up Docker rebuilds?

  • They compress the jar more
  • They split the jar by change frequency so unchanged dependency layers stay cached
  • They remove the need for a JRE
  • They run tests in parallel

Answer: They split the jar by change frequency so unchanged dependency layers stay cached. Layered jars order content by how often it changes; copying least-changed layers first lets Docker cache them, so a code change rebuilds only the small application layer.

Why does a Java container get OOMKilled even when -Xmx looks fine on older JVMs?

  • The JVM sizes the heap from the HOST RAM, ignoring the container limit
  • Containers cannot run Java
  • The JRE has no garbage collector
  • -Xmx is not a real flag

Answer: The JVM sizes the heap from the HOST RAM, ignoring the container limit. Older JVMs read the host machine's memory, so a 512MB-capped container could size the heap for the whole host and get killed (exit 137).

Which flag makes a modern JVM size its heap relative to the container memory limit?

  • -Xmx512m
  • -Dheap=auto
  • -XX:MaxRAMPercentage=75
  • -XX:+UseHostMemory

Answer: -XX:MaxRAMPercentage=75. -XX:MaxRAMPercentage=75 sizes the heap as a percentage of the container's cgroup limit, leaving headroom for metaspace and stacks.

How should secrets like a database password be supplied to a deployed app?

  • Hardcoded into the jar
  • Injected as environment variables from a secret manager at runtime
  • Baked into the Dockerfile
  • Committed to the Git repository

Answer: Injected as environment variables from a secret manager at runtime. Ship one jar and inject secrets via environment variables (referenced with ${ENV_VAR}) from a secret manager — never bake them into the artifact.

How do you select a per-environment Spring profile at launch?

  • Rebuild the jar for each environment
  • Rename the jar to prod.jar
  • Edit the manifest
  • Set SPRING_PROFILES_ACTIVE=prod when running java -jar app.jar

Answer: Set SPRING_PROFILES_ACTIVE=prod when running java -jar app.jar. One jar holds all application-<profile>.properties; SPRING_PROFILES_ACTIVE picks the active profile at runtime.

Which Spring Boot Actuator endpoint do orchestrators use for health checks?

  • /actuator/shutdown
  • /actuator/health
  • /actuator/env
  • /actuator/beans

Answer: /actuator/health. /actuator/health exposes the health signal; Kubernetes uses /actuator/health/liveness and /readiness to decide restarts and traffic routing.

What does server.shutdown=graceful do?

  • Forces an immediate kill
  • Disables the health endpoint
  • Drains in-flight requests before the app stops instead of dropping them
  • Restarts the JVM on error

Answer: Drains in-flight requests before the app stops instead of dropping them. Graceful shutdown lets in-flight requests finish before the process exits, avoiding dropped connections during a deploy or restart.

Continue this course

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.

Related lessons