Optimizing Your Dockerfiles: 4 Key Improvements You Can Apply


When you are getting started with Docker you can get your application running by copying the executable artifact and then run it inside the container, the process would look like this:
1. Start with an Operating System
2. Install the language runtime
3. Copy your application executable
4. Run the application
If you want to build optimized Docker images that are reproducible, small, fast, secure, and cloud-ready, you will need to do a little bit more than the basic setup.
Today, I want to share 4 concrete optimizations I applied to my own Java REST API project, step-by-step, with simple explanations and exactly what you need to set it up in your project.
We'll close with an overview of the different areas you can optimize in Docker builds, depending on your project's needs.
By the end, you’ll not only improve your images but also prepare your applications for cloud environments like AWS, Kubernetes, and beyond.
⚡ Optimization 1: Build the Project Inside the Container
Instead of building your project manually on your machine and copying the final artifact (like a JAR or binary), you can let Docker handle the build process inside the container.
This makes your builds more repeatable and reliable, because Docker will always produce the same results, no matter where it's run — whether on your laptop, a teammate’s laptop, or a CI/CD server.
Let’s see it in action:
Dockerfile (Before):
t# Use an official Java 21 runtime as the base image
FROM eclipse-temurin:21-jdk-alpine
WORKDIR /app
# Copy the pre-built JAR file
COPY target/blog-posts-app.jar app.jar
EXPOSE 8080
CMD ["java", "-jar", "app.jar"]
I used a Java 21 runtime image (
eclipse-temurin:21-jdk-alpine
) that only had the JDK, not Maven.The application was already built locally on my machine using Maven (
mvn package
), and I just copied the pre-built JAR into the container.Docker was only responsible for running the app, not building it.
Dockerfile (After):
# Use Maven + Java 21 as the base image
FROM maven:3.9.4-eclipse-temurin-21-alpine
WORKDIR /app
# Copy the entire project content
COPY . .
# Compile the application inside the container
RUN mvn clean package -DskipTests
EXPOSE 8080
CMD ["java", "-jar", "target/blog-posts-app.jar"]
After:
I switched to a Maven + Java 21 base image (
maven:3.9.4-eclipse-temurin-21-alpine
), which includes both the JDK and Maven.I copied the entire project source code into the container.
I built the project inside the container with Maven using
mvn clean package -DskipTests
.Then I ran the app directly from the freshly built JAR.
⚡ Optimization 2: Use Multi-Stage Build
A multi-stage build means using one stage to build your project (with tools like Maven, npm, etc.) and another stage to run your app with only the minimum files needed.
This keeps your final image clean, small, and secure, because it won’t include unnecessary build tools or source code.
Example flow:
Stage 1: Build your app.
Stage 2: Copy only the compiled app into a lighter runtime image.
To apply this next optimization, we will consider the Dockerfile resulting from the previous section, where we already moved the Maven build inside the container.
Before:
(Same as after the previous optimization — building the project inside the container.)
After:
# -------- Build Stage --------
# Use Maven + Java 21 as the base image
FROM maven:3.9.4-eclipse-temurin-21-alpine AS build
WORKDIR /app
# Copy the entire project content
COPY . .
# Compile the application
RUN mvn clean package -DskipTests
# -------- Runtime Stage --------
FROM eclipse-temurin:21-jdk-alpine
WORKDIR /app
# Copy only the compiled JAR from the build stage
COPY --from=build /app/target/blog-posts-app.jar blog-posts-app.jar
EXPOSE 8080
CMD ["java", "-jar", "blog-posts-app.jar"]
📉 By applying the multi-stage build above, I was able to significantly reduce the size of the final Docker image:
Image Version | Size |
Before multi-stage | 977 MB |
After multi-stage | 671 MB |
✨ Key Changes:
Separation of concerns: I split the Dockerfile into two stages: one for building (
build
) and another for running the app.Build Stage: The first stage uses a Maven image to compile the project.
Runtime Stage: The second stage uses a lighter Java runtime-only image, without Maven or source code.
Final Image is Cleaner and Smaller: Only the compiled
.jar
and the Java runtime are included in the final image.Security and Efficiency: By not including unnecessary build tools (like Maven) or source files in the final image, the attack surface is reduced and deployment is faster.
⚡ Optimization 3: Leverage Docker Layer Caching
Docker builds your images in layers and reuses those layers if nothing has changed.
You can optimize build speed by placing commands that change less frequently (like downloading dependencies) early in your Dockerfile.
This way, when you make small code changes, Docker can reuse cached layers and skip slow steps like redownloading Maven dependencies, resulting in much faster builds.
Before:
(Same as after the previous optimization — we were copying the full project before building.)
After:
# -------- Build Stage --------
FROM maven:3.9.4-eclipse-temurin-21-alpine AS build
# Set the working directory inside the container
WORKDIR /app
# Copy only the pom.xml to download Maven dependencies first
COPY pom.xml .
# Download Maven dependencies and cache them
RUN mvn dependency:go-offline
# Copy the source code
COPY src ./src
# Compile the application
RUN mvn clean package -DskipTests
# -------- Runtime Stage --------
FROM eclipse-temurin:21-jdk-alpine
WORKDIR /app
# Copy only the compiled JAR from the build stage
COPY --from=build /app/target/blog-posts-app.jar blog-posts-app.jar
EXPOSE 8080
CMD ["java", "-jar", "blog-posts-app.jar"]
✨ Key Changes:
Dependency Layer First: I copied
pom.xml
separately before copying the rest of the source code.Cache Dependencies: I ran
mvn dependency:go-offline
early, allowing Docker to cache Maven dependencies unlesspom.xml
changes.Source Code Later: Only after caching dependencies, I copied the
src/
folder.Faster Builds After Small Changes: Now, when I modify my Java code, Docker skips redownloading dependencies and jumps directly to rebuilding the app.
📈 Build Time Comparison
After reorganizing my Dockerfile to leverage layer caching, I ran two consecutive builds:
Build Attempt | Real Build Time |
First build (no cache) | 1 minute 18 seconds |
Second build (using cache) | 1 second |
✅ By leveraging Docker's layer caching, I was able to dramatically reduce build time from over a minute to almost instant for consecutive builds.
This optimization speeds up development workflows and makes CI/CD pipelines much faster and more efficient.
⚡ Optimization 4: Run Your App as a Non-Root User
By default, containers run as the root
user, which can be a security risk if your app gets compromised.
You can improve security by creating a non-root user inside the container and running your app under that user instead.
This follows the principle of least privilege, minimizing the potential damage if a vulnerability is exploited inside the container.
Let’s see it in action:
Before:
(We were running the app as the default root user after copying the JAR.)After:
# -------- Runtime Stage --------
FROM eclipse-temurin:21-jdk-alpine
WORKDIR /app
# Create a non-root user with UID 1000
RUN adduser -D -u 1000 appuser
# Switch to the non-root user
USER appuser
...
✨ Key Changes:
Created a new non-root user: I added a user named
appuser
with UID 1000.Changed the user: I switched the running context to
appuser
before copying the application files.Increased container security: Now, even if the application is compromised, the attacker would have limited permissions inside the container.
Better production practices: Running as a non-root user is often required by security policies in production environments and cloud platforms.
📈 General Areas for Docker Optimization
Area | Why It Matters | Sample Strategies you can use |
🏋️ Image Size | Smaller images pull faster, use less bandwidth, and deploy quicker. | multi-stage builds, Alpine images, copying only what's needed. |
⚡ Build Speed | Faster builds = faster iteration during development and CI/CD. | Optimize layer order, caching dependencies smartly. |
🔒 Security | Containers with minimal permissions and smaller surfaces are safer. | Running as non-root user, minimizing installed packages. |
🔄 Layer Caching | Reduces redundant rebuilds and saves time/money on cloud builds. | Separating dependencies from the source code copy. |
☁️ Cloud & Kubernetes Readiness | Clean, predictable containers deploy easily and scale reliably. | Exposing correct ports, setting environment variables properly. |
Conclusion
By applying simple but powerful optimizations — like building inside the container, using multi-stage builds, leveraging layer caching, and running the app as a non-root user — I was able to make my Docker image smaller, faster, more secure, and more production-ready.
Each small improvement compounds to create images that are easier to build, quicker to deploy, and safer to run — whether locally, in the cloud, or inside Kubernetes.
Mastering these fundamentals sets a strong foundation for building efficient, scalable, and secure containerized applications.
Subscribe to my newsletter
Read articles from Mirna De Jesus Cambero directly inside your inbox. Subscribe to the newsletter, and don't miss out.
Written by

Mirna De Jesus Cambero
Mirna De Jesus Cambero
I’m a backend software engineer with over a decade of experience primarily in Java. I started this blog to share what I’ve learned in a simplified, approachable way — and to add value for fellow developers. Though I’m an introvert, I’ve chosen to put myself out there to encourage more women to explore and thrive in tech. I believe that by sharing what we know, we learn twice as much — that’s precisely why I’m here.