🎯 Objectives
- Demonstrate how to containerize a backend application using Docker
- Highlight common pitfalls and best practices in Dockerfile creation
- Showcase multi-stage builds for leaner, production-ready images
- Implement secure containerization techniques (non-root users, minimal base images)
- Optimize build performance with caching and artifact strategies
- Ensure reproducibility and clarity for CI/CD integration
Why Containerization Isn’t Enough
Containerizing an application is often seen as a checkbox task—docker build, docker run, and you're done. But in production environments, bloated images, insecure defaults, and slow builds can lead to:
- Longer CI/CD cycles
- Vulnerability exposure
- Poor reproducibility across environments
- Optimizing your Dockerfile is not just a performance tweak—it's a security and reliability upgrade.
Containerize the Application
A container is a standardized unit of software development that holds everything that your software application requires to run. This includes relevant code, runtime, system tools, and system libraries.
Containers are created from a read-only template called a container image . Images are typically built from a Dockerfile. A Dockerfile is a plaintext file that specifies all the components that are included in the container.
Let’s start with a basic Java Spring Boot app. A naive Dockerfile might look like this:
FROM openjdk:17
COPY target/app.jar /app.jar
CMD ["java", "-jar", "/app.jar"]
This works, but it’s:
- Heavy: The base image is large
- Insecure: Runs as root by default
- Opaque: No build separation or caching
Build the Docker Image
docker build -t docker-demo .
Starting Point: First Version (Basic)
Here's a basic first version of a Dockerfile with slim Alpine variants eclipse-temurin base image for a Maven-based Java application — it's functional, but not optimized. This is often what developers start with before applying best practices:
FROM eclipse-temurin:17-jdk-alpine
WORKDIR /app
COPY . .
RUN mvn clean package
CMD ["java", "-jar", "target/app.jar"]
Optimizing the Dockerfile
✅ Add a .dockerignore File
A .dockerignore file works like .gitignore, preventing specified files from being sent to the Docker daemon during build. This speeds up builds and prevents sensitive files from being included in your image.
target/
.git
.idea/
*.iml
*.log
*.md
*.bak
✅ Step 1: Use Multi-Stage Build
Multi-stage builds let you use one image for building (with all build tools) and another for running your application. This results in significantly smaller production images and improved security by not including build tools in the final image.
# -------- Stage 1: Build with Maven--------
# Use Eclipse Temurin JDK 17 with Alpine Linux
FROM eclipse-temurin:17-jdk-alpine AS builder
# Set working directory
WORKDIR /app
# Copy pom.xml and maven wrapper download dependencies
COPY ./pom.xml ./pom.xml
COPY ./mvnw ./mvnw
COPY ./.mvn ./.mvn
# Make Maven wrapper executable and download dependencies
RUN chmod +x ./mvnw && ./mvnw dependency:go-offline
# Copy source files and build
COPY src ./src/
# Build the application
RUN ./mvnw clean package -DskipTests && mv target/docker-demo-0.0.1-exec.jar docker-demo.jar && rm -rf target
🔨 Stage Declaration
- Uses Eclipse Temurin JDK 17 with Alpine Linux for minimal footprint
- Names this stage builder for reference in later stages
- Alpine is lightweight but requires manual setup for tools like Maven
📦 Maven Execution
- - chmod +x ./mvnw
- Purpose: Grants execute permission to the Maven Wrapper script (mvnw).
- When copying files into a Docker container, file permissions may not be preserved.
- mvnw ensures a consistent Maven version without requiring the user or CI to have Maven pre-installed
- ./mvnw dependency:go-offline
- Purpose: Pre-downloads all project dependencies, plugins, and transitive dependencies to the local .m2 repository.
📁 Working Directory Setup
- Sets /app as the working directory
- All subsequent paths are relative to this location
⚡ Dependency Optimization
- Copies only the pom.xml first—before source code
- Downloads all dependencies using mvn dependency:go-offline
- Benefit: Docker layer caching ensures dependencies aren’t re-downloaded unless pom.xml changes
🧪 Source Code & Build
- Copies the source code
- Builds the application, skipping tests for faster CI builds
- Renames the JAR for simplicity
- Removes the target directory to reduce image size
✅ Step 2: Use Lightweight Runtime Image
Reduce image size and attack surface. The final image is much smaller and doesn't include Maven or source code.
# -------- Stage 2: Runtime --------
FROM eclipse-temurin:17-jre-alpine AS runtime
✅ Step 3: Run as Non-Root User
Running containers as root is a security risk. Create a non-privileged user and switch to it before running your application. This limits the potential damage if your container is compromised.
# Define build arguments for user and group
ARG USER_ID=1001
ARG GROUP_ID=1001
ARG USERNAME=springuser
ARG GROUPNAME=springuser
# Create group and user using ARGs
RUN addgroup -g ${GROUP_ID} ${GROUPNAME} \
&& adduser -u ${USER_ID} -G ${GROUPNAME} -s /bin/sh -D ${USERNAME}
# Switch to non-root user
USER ${USERNAME}
⚙️ Build Arguments Definition
- These ARG instructions define build-time variables with default values:
- USER_ID=1001: Numeric user ID
- GROUP_ID=1001: Numeric group ID
- USERNAME=springuser: User account name
- GROUPNAME=springuser: Group name
- 🔧 These can be overridden at build time using --build-arg flags for flexibility across environments.
👤 User and Group Creation
This command creates a secure user and group in Alpine Linux:
addgroup: Creates a group with the specified GID
adduser:
- -u: Sets the user ID
- -G: Adds user to the group
- -s /bin/sh: Sets shell
- -D: Creates a system user without a password
🏷️ Ownership Change
Uses chown to change ownership of /app and all its contents
Ensures the non-root user has read/write access to the application directory—including the JAR copied from the builder stage.
👤 Switch to Non-Root User
- Changes the execution context from root to the specified user
- All subsequent instructions (including CMD or ENTRYPOINT) will run as springuser
By running your Spring Boot application as springuser instead of root, you significantly reduce the attack surface and improve container isolation.
✅ Step 4: COPY Artifacts in Multi-Stage Builds
This line is a key part of a multi-stage Dockerfile, used to transfer the compiled Spring Boot application from the build stage to the runtime stage.
# Copy files with ownership set to springuser to avoid chown later
COPY --from=builder --chown=springuser:springgroup /app/docker-demo.jar docker-demo.jar
This command is executed in Stage 2 of a multi-stage build:
- Stage 1 (builder): Uses Maven to compile the Java application and produce a JAR
- Stage 2 (runtime): Uses a lightweight JRE base image to run the application
The JAR is copied from the builder stage into the runtime stage for execution
- COPY: Docker instruction to copy files or directories
- --from=builder: Specifies the source stage (builder) defined earlier
- /app/docker-demo.jar: Source path in the builder stage
- docker-demo.jar: Destination path in the current stage (relative to WORKDIR /app)
✅ Step 5: Set ENTRYPOINT and Optional HEALTHCHECK
ENTRYPOINT defines the executable that runs when the container starts, while CMD provides default arguments to that executable. Using them together makes your containers more flexible and user-friendly.
# Expose application port
EXPOSE 8080
# Alternative using wget (no additional package needed)
HEALTHCHECK --interval=30s --timeout=10s --start-period=60s --retries=3 \
CMD wget --no-verbose --tries=1 --spider http://localhost:8080/actuator/health || exit 1
ENTRYPOINT ["java","-jar","-Dserver.port=8080","/app/docker-demo.jar"]
🌐 Port Exposure
- the container listens on port 8080
- Important: This does not publish the port—use -p 8080:8080 when running the container to map it to the host
- Useful for tooling and orchestration platforms to understand expected traffic ports
❤️ Health Check Configuration
This directive sets up a health probe using Spring Boot’s Actuator endpoint:
- --interval=30s: Runs every 30 seconds
- --timeout=10s: Each check times out after 10 seconds
- --start-period=60s: Waits 60 seconds before starting checks (allows app startup)
- --retries=3: Marks container unhealthy after 3 consecutive failures
The command:
- Uses wget with --spider to check endpoint accessibility without downloading content
- Fails with exit 1 if the health endpoint is unreachable
Ensures the container is only marked healthy when the application is responsive.
🚀 Entrypoint Configuration
Defines the command that runs when the container starts:
- java -jar: Executes the Spring Boot application
- -Dserver.port=8080: Explicitly sets the server port
- /app/docker-demo.jar: Path to the packaged JAR file
✅ Final Optimized Dockerfile
# -------- Stage 1: Build with Maven--------
# Use Eclipse Temurin JDK 17 with Alpine Linux
FROM eclipse-temurin:17-jdk-alpine AS builder
# Set working directory
WORKDIR /app
# Copy pom.xml and maven wrapper download dependencies
COPY ./pom.xml ./pom.xml
COPY ./mvnw ./mvnw
COPY ./.mvn ./.mvn
# Make Maven wrapper executable and download dependencies
RUN chmod +x ./mvnw && ./mvnw dependency:go-offline
# Copy source files and build
COPY src ./src/
# Build the application
RUN ./mvnw clean package -DskipTests && mv target/docker-demo-0.0.1.jar docker-demo.jar && rm -rf target
# -------- Stage 2: Runtime --------
FROM eclipse-temurin:17-jre-alpine AS runtime
# Set the working directory and make it writable by the non-root user
WORKDIR /app
# Define build arguments for user and group
ARG USER_ID=1001
ARG GROUP_ID=1001
ARG USERNAME=springuser
ARG GROUPNAME=springuser
# Create group and user using ARGs
RUN addgroup -g ${GROUP_ID} ${GROUPNAME} \
&& adduser -u ${USER_ID} -G ${GROUPNAME} -s /bin/sh -D ${USERNAME}
# Copy built JAR from builder stage
COPY --from=builder --chown=springuser:springgroup /app/docker-demo.jar docker-demo.jar
# Switch to non-root user
USER ${USERNAME}
# Expose application port
EXPOSE 8080
# Alternative using wget (no additional package needed)
HEALTHCHECK --interval=30s --timeout=10s --start-period=60s --retries=3 \
CMD wget --no-verbose --tries=1 --spider http://localhost:8080/actuator/health || exit 1
ENTRYPOINT ["java","-jar","-Dserver.port=8080","/app/docker-demo.jar"]
Check size of the image, it is about 240MB:
Run the Container
docker run -d -p 8080:8080 docker-demo:latest
How to further reduce image size, even if you're already following best practices like multi-stage builds and slim base images?
Here’s how to further reduce image size, even if you're already following best practices like multi-stage builds and slim base images:
1. Custom Runtime Footprint- jlink Custom Java Runtime
Instead of using a full JRE (even slim ones), you can generate a minimal JRE with only the required Java modules using jlink.
- Uses Eclipse Temurin JDK 17 as the base image (needed for the jdeps and jlink tools)
- Names this stage jre-builder for reference in later stages
- Sets the working directory to /app
- Copies the compiled JAR file from Stage 1 (builder stage)
- Creates a target directory and extracts the JAR contents using jar -xf
- This extraction is necessary because Spring Boot creates "fat JARs" with dependencies in BOOT-INF/lib/
jdeps is a Java dependency analysis tool that identifies which JDK modules are actually used
- --ignore-missing-deps: Ignores missing dependencies (common with Spring Boot fat JARs)
- --multi-release 17: Analyzes for Java 17 multi-release JAR support
- --print-module-deps: Outputs only the required module names
- --class-path: Points to the extracted dependencies in BOOT-INF/lib/
- The output (list of required modules) is saved to jre-deps.text
jlink creates a custom JRE containing only the modules identified by jdeps
- --add-modules $(cat jre-deps.text): Includes only the necessary modules from the analysis
- --compress=2: Applies maximum compression to reduce size
- --strip-debug: Removes debug information
- --no-header-files and --no-man-pages: Excludes unnecessary files
- --output /opt/jre: Creates the custom JRE in /opt/jre
This stage significantly reduces the final Docker image size by creating a minimal JRE that contains only the Java modules actually needed by the Spring Boot application, rather than including the entire JDK or standard JRE.
# ---------- Stage 2: Analyze dependencies with jdeps ----------
FROM eclipse-temurin:17-jdk-alpine AS jre-builder
WORKDIR /app
# Copy fat jar from builder stage
COPY --from=builder /app/docker-demo.jar docker-demo.jar
# Unpack the jar to inspect contents
RUN mkdir target && cd target && cp ../docker-demo.jar docker-demo.jar && jar -xf ../docker-demo.jar
# jdeps is a Java dependency analysis tool. We are printing the module
# dependencies of our application and its libraries.
# The --ignore-missing-deps flag is useful for Spring Boot fat JARs as it may
# report some non-module-system dependencies as missing.
# The output is piped to a file that will be used by jlink.
RUN jdeps --ignore-missing-deps \
--multi-release 17 --print-module-deps \
--class-path="target/BOOT-INF/lib/*" \
target/docker-demo.jar > jre-deps.text
# 3. Create a custom JRE
RUN jlink \
--add-modules $(cat jre-deps.text) \
--compress=2 \
--strip-debug \
--no-header-files \
--no-man-pages \
--output /opt/jre
Stage 3: Runtime
Creates a new build stage named "runtime" using Alpine Linux 3.20 as the base image
- Alpine is chosen because it's extremely lightweight (~5MB), making it ideal for production runtime containers
This is the final stage that will become the actual container image
Copies the custom Java Runtime Environment (JRE) from the previous stage (jre-builder)
The --from=jre-builder flag specifies the source stage in this multi-stage build
This custom JRE was created using jlink in stage 2 and contains only the Java modules needed by the application
By using a custom JRE instead of a full JDK, the final image size is significantly reduced
# -------- Stage 3: Runtime --------
FROM alpine:3.20 AS runtime
# Set the working directory and make it writable by the non-root user
WORKDIR /app
# Define build arguments for user and group
ARG USER_ID=1001
ARG GROUP_ID=1001
ARG USERNAME=springuser
ARG GROUPNAME=springuser
# Create group and user using ARGs
RUN addgroup -g ${GROUP_ID} ${GROUPNAME} && adduser -u ${USER_ID} -G ${GROUPNAME} -s /bin/sh -D ${USERNAME}
# Copy custom JRE from previous stage
COPY --from=jre-builder /opt/jre /opt/jre
# Copy built JAR from builder stage
COPY --from=jre-builder --chown=springuser:springgroup /app/docker-demo.jar docker-demo.jar
# Switch to non-root user
USER ${USERNAME}
# Expose application port
EXPOSE 8080
# Alternative using wget (no additional package needed)
HEALTHCHECK --interval=30s --timeout=10s --start-period=60s --retries=3 \
CMD wget --no-verbose --tries=1 --spider http://localhost:8080/actuator/health || exit 1
ENTRYPOINT ["/opt/jre/bin/java","-jar","-Dserver.port=8080","/app/docker-demo.jar"]
Check size of the image, it is about 123 MB:
The docker history command shows the layer-by-layer history of a Docker image — including:
- Layer sizes
- Commands that created them
- Timestamps
2. Layered JARs & Build pack support
layered JARs, which split your fat JAR into logical layers:
Why It Helps with Docker
Docker caches layers. So when you rebuild your app:
Without layers: Any change (even a single line of code) forces a full image rebuild.
With layers: Only the application layer changes. The rest are cached.
✅Benefits:
- Faster rebuilds in CI/CD pipelines
- Smaller incremental pushes to registries
- Improved startup time (especially with lazy class loading)
How to Use It
<plugin>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-maven-plugin</artifactId>
<configuration>
<layers>
<enabled>true</enabled>
</layers>
</configuration>
</plugin>
Then use layertools to inspect or extract layers:
java -Djarmode=layertools -jar target/{your-jar-name}.jar extract
You get:
/app
├── dependencies/
├── snapshot-dependencies/
├── spring-boot-loader/ ✅ contains JarLauncher.class
├── application/
Dockerfile(without good practice ):
# Use multi-stage build
FROM eclipse-temurin:17-jdk-alpine AS builder
# Set working directory
WORKDIR /app
# Copy pom.xml and maven wrapper to download dependencies
COPY ./pom.xml ./pom.xml
COPY ./mvnw ./mvnw
COPY ./.mvn ./.mvn
RUN chmod +x ./mvnw && ./mvnw dependency:go-offline
# Copy source files and build
COPY src ./src/
RUN ./mvnw clean package -DskipTests
# Extract layered JAR using layertools (fixed JAR filename)
RUN java -Djarmode=layertools -jar target/docker-demo-0.0.1.jar extract
# Clean up Maven repository to reduce image size
RUN rm -rf ~/.m2/repository
# Runtime stage
FROM eclipse-temurin:17-jre-alpine AS runtime
WORKDIR /app
# Copy layers from the builder stage
COPY --from=builder /app/dependencies/ ./
COPY --from=builder /app/snapshot-dependencies/ ./
COPY --from=builder /app/spring-boot-loader/ ./
COPY --from=builder /app/application/ ./
ENTRYPOINT ["java", "org.springframework.boot.loader.launch.JarLauncher"]
Layered JARs do not directly reduce the total image size, but they optimize your Docker images in smarter ways.
Buildpack support provides the Java runtime for the application, so it’s now possible to skip the Dockerfile and build the Docker image automatically
✅ 1. Build the Image
./mvnw spring-boot:build-image -DskipTests
The command uses Cloud Native Buildpacks (CNB) to automatically create a secure, optimized, and production-ready container image directly from your source code, without needing a Dockerfile.
✅ 2. Run the Image
docker run -p 8080:8080 my-app:0.0.1-SNAPSHOT
✅ 3. Customize the Build Image (Optional)
Use environment variables or configuration:
./mvnw spring-boot:build-image \
-Dspring-boot.build-image.imageName=my-org/my-app \
-Dspring-boot.build-image.builder=paketobuildpacks/builder:tiny \
-Dspring-boot.build-image.env.BP_JVM_VERSION=17
Security & Production Readiness
- Paketo buildpacks ensure:
- No root user by default
- Minimal base OS (Tiny <50MB)
- Regular vulnerability scans (via CVEs)
- JVM flags optimized for container environments
Head-to-Head Comparison
3. Dockerfile for GraalVM Native Image
GraalVM is a high-performance JIT (Just-In-Time) and AOT (Ahead-of-Time) compiler that enables Java applications to be compiled into native code.
They are well suited to applications that are deployed using container images and are especially interesting when combined with "Function as a service" (FaaS) platforms.
A GraalVM Native Image is a complete, platform-specific executable. You do not need to ship a Java Virtual Machine in order to run a native image.
Key Advantages of GraalVM:
- ✅ Instant startup time — Runs natively without requiring a JVM!
- ✅ Lower memory consumption — Uses significantly less RAM compared to the traditional JVM.
- ✅ Smaller Docker containers — Ideal for cloud-native architectures.
- ✅ Enhanced security — Eliminates unnecessary runtime components, reducing the attack surface.
Key Differences with JVM Deployments
The fact that GraalVM Native Images are produced ahead-of-time means that there are some key differences between native and JVM based applications. The main differences are:
- Static analysis of your application is performed at build-time from the main entry point.
- Code that cannot be reached when the native image is created will be removed and won’t be part of the executable.
- GraalVM is not directly aware of dynamic elements of your code and must be told about reflection, resources, serialization, and dynamic proxies.
- The application classpath is fixed at build time and cannot change.
- There is no lazy class loading, everything shipped in the executables will be loaded in memory on startup.
- There are some limitations around some aspects of Java applications that are not fully supported.
Why Is GraalVM Native Build Slow?
- Heavy Static Analysis GraalVM performs deep static analysis to eliminate unused code and handle reflection, proxies, and dynamic features. This takes time.
- No Runtime Profiling by Default Unlike JIT, GraalVM AOT lacks runtime profiling unless you use Profile-Guided Optimization (PGO).
- Large Classpath Spring Boot apps often pull in dozens of libraries (JPA, Web, Security), which bloats the analysis phase.
- Reflection & Dynamic Proxies These require manual configuration or tracing via native-image-agent, which adds build overhead.
- Native image compilation is very memory-intensive (can use several GBs of RAM).
The -Pnative profile is used to generate a native executable for your platform. This will generate a native executable called docker-demo in the target/ directory.
Add native-maven-plugin in pom.xml file
<profiles>
<profile>
<id>native</id>
<build>
<plugins>
<plugin>
<groupId>org.graalvm.buildtools</groupId>
<artifactId>native-maven-plugin</artifactId>
<version>0.10.6</version>
<extensions>true</extensions>
<executions>
<execution>
<id>build-native</id>
<goals>
<goal>compile-no-fork</goal>
</goals>
<phase>package</phase>
</execution>
</executions>
</plugin>
</plugins>
</build>
</profile>
Dockerfile:
# STAGE 1: Builder - Note- latest is error prone
FROM ghcr.io/graalvm/graalvm-ce:latest AS builder
# Install native-image component
RUN gu install native-image
# Set working directory
WORKDIR /app
# Copy pom.xml and maven wrapper download dependencies
COPY ./pom.xml ./pom.xml
COPY ./mvnw ./mvnw
COPY ./.mvn ./.mvn
# Make maven wrapper executable
RUN chmod +x ./mvnw
RUN ./mvnw dependency:go-offline -B
# Copy source files and build
COPY src ./src/
# Build the application
RUN ./mvnw package -Pnative -Dquarkus.container-image.build=false -DskipTests
FROM alpine:3.20 AS runtime
WORKDIR /app
# Create non-root user
RUN addgroup -g 1001 -S appgroup && \
adduser -u 1001 -S appuser -G appgroup \
COPY --from=builder /app/target/docker-demo ./app
RUN chmod +x ./app
USER appuser
# Expose application port
EXPOSE 8080
CMD ["./app"]
Tip :
If you want to go native please look at Quarkus. It was built with native in mind. Porting an existing Springboot app to Spring native is a lot of trial and error. Spring relies heavily on reflection and AOP, while GraalVM is all about having everything ready at compile time.
Common Pitfalls in Dockerfile Creation
Here’s a curated list of common pitfalls in Dockerfile creation, especially relevant for backend engineers and DevOps professionals working with CI/CD pipelines and production-grade containers.
- Using a large base image
- Problem: Increases image size and build time.
- ❌ FROM openjdk:17
- ✅ Use slim or Alpine variants (eclipse-temurin:17-jdk-alpine, eclipse-temurin:17-jre)
- Installing unnecessary packages
- Problem: Leads to bloated images and potential security
- ✅Install only required packages with --no-install-recommends vulnerabilities.
- Failing to use multi-stage builds
- Problem: Development tools remain in the final image.
- ❌ Build tools and source code included in final image
- ✅ Separate build and runtime stages
- Ignoring layer caching
- ❌ Forces Maven to re-download dependencies and rebuild everything.
COPY . .
RUN mvn clean install
- ✅ Order COPY and RUN to isolate stable steps (e.g., dependencies first)
# Copy only dependency-related files first
COPY pom.xml ./
RUN mvn dependency:go-offline
# Then copy the rest of the source
COPY src/ ./src/
RUN mvn clean package
-
Using latest tag for base images
- Problem: Builds can break due to unexpected updates.
- ❌ FROM node:latest
- ✅ Pin exact versions (FROM node:18.16.0)
-
Running as Root
- Unnecessary Privileges and Security Vulnerability
- ❌ Default user is root
- ✅ Create and switch to a non-root user (USER appuser)
-
Not specifying a working directory
- Problem: Commands may run in unexpected directories.
- ✅ Use WORKDIR /app before COPY or RUN
-
Not cleaning up temporary files
- Problem: Increases image size.
- ✅ Delete temp files (rm -rf /tmp/, /build/) after use
-
Not Using .dockerignore
- ❌ Sending unnecessary files (e.g., .git, target/, node_modules/)
- ✅ Add a .dockerignore file
-
Missing Health Checks
- ❌ No visibility into container health
- ✅ Use HEALTHCHECK with /actuator/health or similar
-
Hardcoding secrets
- ✅Inject secrets via CI/CD or use secret managers.
References
💡 Buildpacks & Spring Boot Integration — Spring Boot Documentation
🧠 AI Assistance — Content and explanations are partially supported by ChatGPT, Microsoft Copilot, and GitLab Duo, following Spring Boot and Docker best practices and OCI standards.
Top comments (0)