← Back to the section

Building an image is easy — but a bad image drags extra gigabytes, open vulnerabilities, and, in the worst case, secrets that were never meant to be there into production. In this article we'll look at the practices that separate an image that "works" from one that's "ready for production".

Why size and security matter

When a developer builds their first image, they usually grab the most obvious base image — say, openjdk:latest — and add the application on top. It works. But an image like that weighs several gigabytes and ships a compiler, debuggers, a package manager, and hundreds of other packages the app will never need in production.

The problems go beyond size:

  • Every extra package is a potential vulnerability. If the image contains curl or bash, an attacker who gets into the container instantly has a convenient toolkit.
  • Large images take longer to pull during deployment — especially noticeable with horizontal scaling, when new nodes spin up in seconds.
  • The smaller the image, the faster a vulnerability scanner checks it and the fewer false positives you get.

The short formula: less in the image → smaller attack surface.

Choosing a base image

For Spring Boot Java applications, the executable jar doesn't need a JDK — it only needs a JRE. That's the first step toward a smaller image.

Three base image options, from largest to smallest:

eclipse-temurin with JRE

eclipse-temurin:21-jre is the recommended choice for most cases. It's the official OpenJDK image from Eclipse Adoptium, built on Debian slim. It contains only the JRE, weighs ~200 MB, is well supported, and is compatible with common diagnostic tools.

FROM eclipse-temurin:21-jre
WORKDIR /app
COPY app.jar app.jar
ENTRYPOINT ["java", "-jar", "app.jar"]

Alpine variants

eclipse-temurin:21-jre-alpine shaves off another 50–80 MB thanks to the minimalist Alpine Linux distribution. The downside is that Alpine uses musl instead of glibc. For most Spring Boot applications this isn't a problem, but some native libraries (for example, BouncyCastle with native extensions) may behave unexpectedly. Verify on your test suite before switching.

Distroless

Google's gcr.io/distroless/java21-debian12 images contain no shell, no package manager, and no utilities at all — only the Java runtime and minimal system libraries. The attack surface is minimal.

FROM gcr.io/distroless/java21-debian12
WORKDIR /app
COPY app.jar app.jar
ENTRYPOINT ["java", "-jar", "app.jar"]

The price for that security is that debugging inside the container is impossible: no sh, no ls, nothing. For diagnostics you use docker cp, external profilers, or a temporary sidecar container with the tools you need.

How to choose: for a new project, start with eclipse-temurin:21-jre and move to distroless when you tighten your security requirements.

Pinned tags instead of latest

FROM eclipse-temurin:latest is a hidden time bomb. On your next build the image can quietly change: a new Java version, a different OS, different dependencies. An image that worked in development will break in production.

Pin the version explicitly:

# bad — what actually gets pulled is unpredictable
FROM eclipse-temurin:latest

# good — the version is pinned
FROM eclipse-temurin:21.0.5_11-jre-jammy

Even more reliable is pinning by digest (hash):

FROM eclipse-temurin:21-jre@sha256:abc123...

This guarantees byte-for-byte identical images on every build. Tags should be updated deliberately, not by accident.

Multi-stage build

A typical mistake is to build the jar right inside a JDK image and then leave the JDK in the final image. As a result, a compiler that's completely unnecessary there ends up in production.

A multi-stage build splits the process into stages: one image for building, another for running. The final image contains only what the application needs.

# --- build stage ---
FROM eclipse-temurin:21-jdk-jammy AS builder
WORKDIR /build

# copy only the dependency files first — the layer stays cached
# as long as pom.xml/build.gradle hasn't changed
COPY build.gradle settings.gradle gradlew ./
COPY gradle/ gradle/
RUN ./gradlew dependencies --no-daemon

# now copy the sources and build
COPY src/ src/
RUN ./gradlew bootJar --no-daemon

# --- final image ---
FROM eclipse-temurin:21-jre-jammy
WORKDIR /app
# copy only the jar from the builder stage
COPY --from=builder /build/build/libs/app.jar app.jar
ENTRYPOINT ["java", "-jar", "app.jar"]

The JDK, Gradle, and the dependency cache all stay in the intermediate layer and never reach the final image. For more on layers and multi-stage builds, see Multi-stage builds and image layers.

.dockerignore — what to keep out of the build context

When you run docker build, Docker sends the entire build context — the contents of the current directory — to the daemon. If it contains a 500 MB node_modules/, a target/ from a previous build, or a .env file with passwords, all of it flies off to the daemon and may settle in the image layers.

The .dockerignore file works just like .gitignore — it lists what to exclude:

# results of previous builds
build/
target/
.gradle/

# environment files and secrets
.env
*.env
*.key
*.pem

# IDE and OS
.idea/
.DS_Store
*.iml

# test sources (if not needed in the image)
src/test/

The rule is simple: anything not needed to run the application should not enter the context.

Running as a non-root user

By default, processes inside a container run as root. That's convenient during development, but in production it creates a risk: if an attacker breaks out of the container, they gain access to the host with superuser privileges.

The USER instruction lets you switch to a regular user:

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

# create a system user with no home directory and no shell
RUN addgroup --system appgroup && \
    adduser --system --ingroup appgroup --no-create-home appuser

COPY --chown=appuser:appgroup app.jar app.jar

# switch before startup
USER appuser

ENTRYPOINT ["java", "-jar", "app.jar"]

--chown ensures the application file belongs to this user. The application doesn't need root privileges to read the jar and start the JVM.

Secrets don't belong in the image

This is a critically important rule that gets broken more often than you'd think.

What must never go into a Dockerfile or an image:

# NEVER — the token stays in the layer history forever
ENV DATABASE_PASSWORD=supersecret

# NEVER — even if you delete it later, the layer with the secret persists
COPY .env /app/.env
RUN rm /app/.env  # deleting doesn't help!

Docker builds an image layer by layer, and every RUN/COPY is a separate layer. Even if a secret is removed in the next instruction, it stays in the previous layer forever and is accessible through docker history or docker save.

The right approach is to pass secrets through environment variables when starting the container, through secret stores (Docker Secrets, Vault, AWS Secrets Manager), or through the --mount=type=secret mechanism at build time:

# right — pass the secret at run time, not at build time
docker run -e DATABASE_PASSWORD="$DB_PASS" myapp:1.0

In Spring Boot, the value of the environment variable is automatically substituted into application.properties via ${DATABASE_PASSWORD}.

Scanning images for vulnerabilities

Even a properly built image can contain vulnerabilities in base system libraries. Scanners check the image against CVE databases and produce a report: which package, which vulnerability, and whether a fixed version exists.

Docker Scout is built into Docker Desktop and Docker Hub:

# scan a local image
docker scout cves myapp:1.0

# a short summary
docker scout quickview myapp:1.0

Trivy is a popular independent scanner with broad capabilities:

# install via brew (macOS)
brew install trivy

# scan an image
trivy image myapp:1.0

# only high and critical vulnerabilities
trivy image --severity HIGH,CRITICAL myapp:1.0

Both tools integrate well into CI/CD — you can fail the build pipeline when critical vulnerabilities are found. This turns the security check from a manual step into an automated quality gate.

For more on publishing images and building scanning into the pipeline, see Registries and CI/CD for images.

Production image checklist

Before an image ships to production, run through this list:

  • The base image contains only the JRE, not a JDK
  • The base image tag is pinned (not latest)
  • A multi-stage build is used — the JDK and build tools don't end up in the final image
  • .dockerignore excludes test files, caches, and environment files
  • The application runs as a non-root user (USER)
  • There are no passwords, tokens, or keys in the Dockerfile — they're passed in from outside at run time
  • The image has been checked with a scanner (docker scout or trivy)

In short

  • Choose a minimal base image: eclipse-temurin:21-jre for most cases, distroless for maximum security.
  • Pin the version by tag or digest — latest hides unexpected updates.
  • A multi-stage build keeps the JDK and build tools out of the final image.
  • .dockerignore stops caches, IDE files, and environment files from entering the context.
  • Run the process as a non-root user — minimize the damage from a breach.
  • Secrets never go into a Dockerfile or image layers — they're only passed in at run time.
  • Regular scanning (docker scout, trivy) surfaces vulnerabilities in system libraries.

Further reading

  • Multi-stage builds and image layers — how Docker caches layers and why the order of instructions in a Dockerfile matters.
  • The JVM inside a container — why the JVM doesn't see the right amount of memory and how to fix it with flags.
  • Registries and CI/CD for images — how to publish images and build the process into a pipeline automatically.