The last project uses a genuinely real example: the multi-stage Dockerfile pattern used to build and ship this very Spring Boot site. It solves a problem the earlier Node example did not have to deal with β€” compiling the app is itself a heavyweight step that needs its own toolchain.

The problem with a single-stage build

A Spring Boot app needs Maven and a full JDK to compile, but running the finished app only needs a much smaller JRE. Installing Maven and a JDK into your final runtime image bloats it with build tools nobody needs once the jar exists, and it means every build tool ever gets shipped to production. A multi-stage build fixes this by using one temporary image to compile the app, then copying just the finished artifact into a second, much leaner image.

Stage 1: build

FROM maven:3.9.9-eclipse-temurin-17 AS build
WORKDIR /app

COPY pom.xml .
RUN mvn -q -B dependency:go-offline

COPY src ./src
RUN mvn -q -B -DskipTests package

AS build names this stage so it can be referenced later. Just like the Node example, dependencies are resolved in their own step, using dependency:go-offline to download every Maven dependency, before the actual source code is copied in β€” so an ordinary code change reuses the cached dependency layer instead of re-downloading the internet on every build. -DskipTests skips running the test suite during the image build itself; tests belong in CI, run once, not repeated on every single Docker build.

Stage 2: runtime

FROM eclipse-temurin:17-jre-jammy
WORKDIR /app

RUN addgroup --system app && adduser --system --ingroup app app
COPY --from=build /app/target/*.jar app.jar
RUN chown app:app app.jar
USER app

EXPOSE 8080
ENV JAVA_OPTS=""

ENTRYPOINT ["sh", "-c", "java $JAVA_OPTS -jar app.jar"]

This second FROM starts a completely fresh image based only on a JRE (no Maven, no JDK, no source code) β€” eclipse-temurin:17-jre-jammy instead of the much larger maven:... image from stage 1. COPY --from=build /app/target/*.jar app.jar is the key line: it reaches back into the first stage and pulls out only the compiled jar file, leaving every build tool and every intermediate file behind.

Running as a non-root user

The addgroup / adduser / USER app sequence creates an unprivileged system user and switches to it before the app runs. Containers run as root by default unless told otherwise, and if an attacker ever found a way to execute code inside the container, running as root would hand them far more power over that container than they need. This is a small addition with a real security payoff, and it is standard practice for any production image.

Reading configuration from the environment

# application.yml
server:
  port: ${PORT:8080}

Because the port is read from an environment variable with a sensible default, the exact same image can be deployed to a platform like Google Cloud Run, which injects its own PORT value at startup, without changing a single line of application code or the Dockerfile itself.

Building and running the whole thing

docker build -t my-spring-app .
docker run -p 8080:8080 my-spring-app

Docker automatically runs both stages in order and discards everything from the build stage except what was explicitly copied out with --from=build β€” the final image contains only a JRE, a non-root user, and one jar file.

Why this exact pattern is worth remembering

  • Smaller final image β€” no build tools, no source code, no intermediate Maven cache shipped to production.
  • Faster, cached rebuilds β€” dependencies download once and are reused across builds until pom.xml actually changes.
  • Better security β€” a non-root user with the smallest possible attack surface.
  • Environment-driven configuration β€” one image, deployable anywhere, configured entirely at runtime.

This two-stage shape β€” a heavy build stage, a lean runtime stage, only the compiled artifact copied across β€” is the pattern to reach for with any compiled language: Java, Go, .NET or C++ all benefit from exactly this same structure.