Skip to main content

    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.

    Maven Shade — produce target/app.jar
    <!-- 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>
    Output
    $ 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:8080
    This is real code — run it for free atonecompiler.com/javaor in your own editor.

    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 (dockerfile)
    # 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"]
    Output
    $ 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:8080
    Run this in your own terminal or editor to see it work.

    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.

    Layered jar Dockerfile (dockerfile)
    # 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"]
    Output
    $ 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
    Run this in your own terminal or editor to see it work.

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

    Complete the multi-stage build (dockerfile)
    # 🎯 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.
    Run this in your own terminal or editor to see it work.

    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.

    Spring profiles per environment (bash)
    # 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
    Output
    $ 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)
    This is real code — run it for free atonecompiler.com/bashor in your own editor.

    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.

    Actuator health, probes & graceful shutdown (java)
    // 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
            }
        }
    }
    Output
    $ 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
    This is real code — run it for free atonecompiler.com/javaor in your own editor.

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

    Wire up Actuator + profiles (bash)
    # 🎯 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.
    This is real code — run it for free atonecompiler.com/bashor in your own editor.

    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.

    Container-aware heap sizing (java)
    // 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);
        }
    }
    Output
    # 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 too
    This is real code — run it for free atonecompiler.com/javaor in your own editor.

    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.

    GitHub Actions: build, test, image, deploy (yaml)
    # .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=staging
    Output
    Run 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 58s
    Run this in your own terminal or editor to see it work.

    Common 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-alpine runtime 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 a HEALTHCHECK in the Dockerfile.
    • Hardcoded config / secrets: a baked-in jdbc:postgresql://prod-db or 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=75 so 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.

    Containerise a Spring Boot service (dockerfile)
    # 🎯 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
    Run this in your own terminal or editor to see it work.

    📋 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

    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.

    Previous

    Cookie & Privacy Settings

    We use cookies to improve your experience, analyze traffic, and show personalized ads. You can manage your preferences below.

    By clicking "Accept All", you consent to our use of cookies for analytics and personalized advertising. You can customize your preferences or reject non-essential cookies.

    Privacy PolicyTerms of Service

    Install LearnCodingFast

    Learn faster with the app on your home screen.