As a best-selling author, I invite you to explore my books on Amazon. Don't forget to follow me on Medium and show your support. Thank you! Your support means the world!
Container images are how we package and ship Java applications today. Think of them like shipping containers for code. A well-built container gets your application to its destination—whether that's a cloud server, a Kubernetes cluster, or a developer's laptop—quickly, securely, and without wasting resources. A poorly built one is slow, bloated, and potentially full of holes.
I've learned that building a good Java container isn't about using the fanciest tools. It's about applying a handful of clear, deliberate techniques. These methods help you create images that start fast, stay small, and run safely. Let's walk through five of the most effective approaches.
First, let's talk about image size. It might seem like a minor detail, but it matters more than you think. A large image takes longer to upload to a registry and longer for a server to download when starting your application. This slows down every deployment, update, and scale-up event. It also uses more disk space on every machine that runs it. In the cloud, where you might be running hundreds of instances, that wasted space adds real cost.
The most straightforward way to shrink your image is to use a multi-stage build. Here's the core idea: you use one container, full of build tools, to compile your Java code and package it. Then, you use a second, much cleaner container just to run it. You copy only the finished application files from the builder container into the runner. All the heavy compilers and build dependencies get left behind.
For example, you need a full Java Development Kit (JDK) to compile code. But to run the code, you often only need the smaller Java Runtime Environment (JRE). A multi-stage build lets you use the JDK in the first stage and the JRE in the final stage.
# This is the build stage. It's temporary.
FROM eclipse-temurin:17-jdk-alpine AS builder
WORKDIR /workspace
COPY . .
# Here, we compile and package the application.
RUN ./mvnw package -DskipTests
# This is the final, runtime stage. This is what gets shipped.
FROM eclipse-temurin:17-jre-alpine
RUN addgroup -S spring && adduser -S spring -G spring
USER spring:spring
WORKDIR /app
# We copy *only* the jar file from the builder stage.
COPY --from=builder /workspace/target/myapp.jar app.jar
ENTRYPOINT ["java", "-jar", "/app/app.jar"]
The final image from this Dockerfile contains just the operating system, the JRE, your JAR file, and nothing else. You can often cut the image size by half or more using this pattern. It's your first and most powerful tool for efficiency.
Now, let's discuss how the Java Virtual Machine (JVM) behaves inside a container. This tripped me up early on. By default, a traditional JVM doesn't know it's inside a container. It looks at the host machine's total memory and sets its own memory limits based on that, which can be a problem. If your container is limited to 1GB of memory, but the JVM thinks it has 16GB available, it will make poor decisions that can lead to crashes.
Modern JVMs from OpenJDK and Eclipse Temurin are "container-aware." You need to tell them to use this feature. This ensures the JVM respects the limits you set for your container.
FROM eclipse-temurin:17-jre-alpine
COPY target/app.jar app.jar
# These flags are crucial for running in containers.
ENV JAVA_TOOL_OPTIONS="-XX:+UseContainerSupport \
-XX:MaxRAMPercentage=75.0 \
-XX:+UseG1GC \
-XX:MaxGCPauseMillis=200"
ENTRYPOINT ["java", "-jar", "app.jar"]
The UseContainerSupport flag is the key. It tells the JVM to check the container's memory limits, not the host's. MaxRAMPercentage=75.0 tells it to use no more than 75% of the container's memory limit for the Java heap, leaving some room for other processes. The other flags tune the G1 garbage collector for better performance in a constrained environment. You wouldn't use these exact settings for every application, but the principle is vital: always configure the JVM for its container environment.
Next, we need to understand how Docker builds images in layers. Every command in a Dockerfile creates a new layer. Docker caches these layers. If you change a line in your Dockerfile and rebuild, Docker will re-execute that command and every command after it. It will reuse the cache for commands that haven't changed.
You can use this to make your builds much faster. The trick is to order your commands from the least likely to change to the most likely to change.
# Start with a stable base.
FROM eclipse-temurin:17-jre-alpine
# 1. Install system dependencies. This rarely changes.
RUN apk add --no-cache curl tzdata
# 2. Set up the filesystem and non-root user. This changes infrequently.
RUN addgroup -S app && adduser -S app -G app
WORKDIR /app
RUN chown -R app:app /app
USER app
# 3. Copy dependency files (like a pre-downloaded lib folder).
# If dependencies haven't changed, this layer is cached.
COPY --chown=app:app ./lib ./lib
# 4. COPY the application last. This changes with *every* code change.
COPY --chown=app:app ./myapp.jar ./app.jar
ENTRYPOINT ["java", "-jar", "app.jar"]
In this example, if you only change your application code (resulting in a new myapp.jar), steps 1, 2, and 3 will be loaded from the cache. Docker only needs to execute the final COPY command. This can turn a 2-minute build into a 2-second build. For Java, this often means separating your application dependencies from your application code in the build process to maximize cache use.
Security cannot be an afterthought. A container image includes an operating system (albeit a minimal one) and all your application's libraries. Both can have vulnerabilities. You must scan your images for known security issues before you deploy them.
This isn't just about choosing a secure base image. It's about checking every layer you add. Tools like Trivy, Grype, or Docker Scout can analyze your image and compare its contents against databases of known vulnerabilities.
You integrate scanning into your build pipeline. Here’s a conceptual example of how you might structure a Dockerfile with security in mind:
# Use a specific, verified version of a minimal base image.
FROM eclipse-temurin:17.0.8_7-jre-alpine@sha256:abc123def456...
# Create a non-root user to run the application.
RUN addgroup -S appgroup && adduser -S appuser -G appgroup
USER appuser
# Copy the application as the non-root user.
COPY --chown=appuser:appgroup target/app.jar /app/app.jar
WORKDIR /app
# Use a minimal entry point.
ENTRYPOINT ["java", "-jar", "app.jar"]
# Add a health check.
HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \
CMD wget -q -O- http://localhost:8080/actuator/health || exit 1
The @sha256 pinning ensures you get an exact, immutable base image. Running as a non-root user limits the damage if an attacker finds a way into the container. The health check helps your orchestration tool (like Kubernetes) know if your app is healthy. After building this image, you would run a scanner on it:
# Example using Trivy
trivy image mycompany/my-java-app:latest
# The build should fail if critical vulnerabilities are found.
Finally, let's consider reproducibility. You should be able to build the same container image today, tomorrow, and next month. If you need to debug an issue in production, you want to rebuild the exact image that is running. If you can't, you're debugging in the dark.
Reproducible builds come from pinning versions and controlling dependencies.
# Pin EVERYTHING: base OS, Java version, and even the base image's hash.
FROM alpine:3.18.3 AS base
RUN apk add --no-cache wget
FROM eclipse-temurin:17.0.8_7-jdk-alpine AS builder
WORKDIR /build
# Copy your dependency lock file (e.g., from Gradle or Maven).
COPY gradle/wrapper gradle/wrapper
COPY gradle.properties .
COPY build.gradle .
COPY settings.gradle .
# This layer will be cached as long as your build files don't change.
RUN ./gradlew dependencies
COPY src ./src
RUN ./gradlew build -x test
# Final stage also uses a pinned runtime.
FROM eclipse-temurin:17.0.8_7-jre-alpine@sha256:def789abc123...
LABEL maintainer="team@mycompany.com" \
version="1.2.3" \
build-date="2023-10-27T10:00:00Z"
COPY --from=builder /build/build/libs/myapp.jar /app.jar
ENTRYPOINT ["java", "-jar", "/app.jar"]
The key practices here are using a version lock file for your Java dependencies (like gradle.lockfile or Maven's pom.xml with exact versions) and pinning the versions of all your base images. The LABEL instructions embed useful metadata directly into the image.
When you combine these five techniques, you get a robust process. You start with a multi-stage build to keep the image small. You add JVM tuning so it runs well inside container limits. You structure your Dockerfile to leverage layer caching for fast builds. You integrate security scanning to catch vulnerabilities early. And you pin all your dependencies to ensure the build is reproducible.
The result isn't just a container. It's a reliable, efficient, and secure package for your Java application. It will deploy quickly, run predictably, and give your operations team confidence. Building containers this way turns a potential source of complexity into a straightforward, automated part of your delivery pipeline. You stop worrying about the packaging and can focus on what matters: the application inside.
📘 Checkout my latest ebook for free on my channel!
Be sure to like, share, comment, and subscribe to the channel!
101 Books
101 Books is an AI-driven publishing company co-founded by author Aarav Joshi. By leveraging advanced AI technology, we keep our publishing costs incredibly low—some books are priced as low as $4—making quality knowledge accessible to everyone.
Check out our book Golang Clean Code available on Amazon.
Stay tuned for updates and exciting news. When shopping for books, search for Aarav Joshi to find more of our titles. Use the provided link to enjoy special discounts!
Our Creations
Be sure to check out our creations:
Investor Central | Investor Central Spanish | Investor Central German | Smart Living | Epochs & Echoes | Puzzling Mysteries | Hindutva | Elite Dev | Java Elite Dev | Golang Elite Dev | Python Elite Dev | JS Elite Dev | JS Schools
We are on Medium
Tech Koala Insights | Epochs & Echoes World | Investor Central Medium | Puzzling Mysteries Medium | Science & Epochs Medium | Modern Hindutva
Top comments (0)