Deployment

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.

Learn Deployment in our free Java course — a beginner-friendly interactive lesson with worked examples, a practice exercise and a quick reference.

Part of the free Java course at LearnCodingFast — hands-on lessons with examples you run in your browser, plus practice exercises and a quick quiz.

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.

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.

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.

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.

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.

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.

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.

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 {'$ '} placeholders and inject the real values from the environment or a secret manager at runtime.

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.

Fill in the blanks to expose a health endpoint, enable Kubernetes probes, shut down gracefully, and launch with the prod profile.

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.

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.

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.

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.

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.