Header image

Build Smarter: Best Practices for Creating Optimized Dockerfile

08/07/2025

642

If you’ve been using Docker in your projects, you probably know how powerful it is for shipping consistent environments across teams and systems. It’s time to learn how to optimize dockerfile.

But here’s the thing: a poorly written Dockerfile can quickly become a hidden performance bottleneck. Making your images unnecessarily large, your build time painfully slow, or even causing unexpected behavior in production.

I’ve seen this firsthand—from early projects where we just “made it work” with whatever Dockerfile we had, to larger systems where the cost of a bad image multiplied across services.

My name is Bao. After working on several real-world projects and going through lots of trial and error. I’ve gathered a handful of practical best practices to optimize Dockerfile that I’d love to share with you. Whether you’re refining a production-grade image or just curious about what you might be missing. Let me walk you through how I approach Docker optimization. Hopefully it’ll save you time, headaches, and a few docker build rage moments 😅.

Identifying Inefficiencies in Dockerfile: A Case Study

Below is the Dockerfile we’ll analyze:

Creating Optimized Dockerfile

Key Observations:

1. Base Image:

  • The Dockerfile uses ubuntu:latest, which is a general-purpose image. While versatile, it is significantly larger compared to minimal images like ubuntu:slim or Node.js-specific images like node:20-slim, node:20-alpine.

2. Redundant Package Installation:

  • Tools like vim, wget, and git are installed but may not be necessary for building or running the application.

3. Global npm Packages:

  • Pages like nodemon, ESLint, and prettier are installed globally. These are typically used for development and are not required in a production image.

4. Caching Issues:

  • COPY . . is placed before npm install, invalidating the cache whenever any application file changes, even if the dependencies remain the same.

5. Shell Customization:

  • Setting up a custom shell prompt (PS1) is irrelevant for production environments, adding unnecessary steps.

6. Development Tool in Production:

  • The CMD uses nodemon, which is a development tool, to run the application

Optimized your Docker Image

Here’s how we can optimize the Dockerfile step by step. Showing the before and after for each section with the result to clearly distinguish the improvements.

1. Change the Base Image

Before:

FROM ubuntu:latest

RUN apt-get update && apt-get install -y curl &&
    curl -fsSL https://deb.nodesource.com/setup_20.x | bash - && \
    apt-get install -y nodejs
  • Use ubuntu:latest, a general-purpose image that is large and includes many unnecessary tools.

After:

FROM node:20-alpine
  • Switches to node:20-alpine, a lightweight image specifically tailored for Node.js applications.

Result:

Change the Base Image
  • With the first change being applied, the image size is drastically reduced by about ~200MB. 

2. Simplify Installed Packages

Before:

RUN apt-get update && apt-get install -y \
    curl \
    wget \
    git \
    vim \
    python3 \
    make \
    g++ && \
    curl -fsSL https://deb.nodesource.com/setup_20.x | bash - && \
    apt-get install -y nodejs
  • Installs multiple tools (curl, wget, vim, git) and Node.js manually, increasing the image size and complexity.

After:

RUN apk add --no-cache python3 make g++
  • Uses apk (Alpine’s package manager) to install only essential build tools (python3, make, g++).

Result:

Simplify Installed Packages
  • The image should be cleaner and smaller after removing the unnecessary tools, packages. (~250MB vs ~400MB with the older version)

3. Leverage Dependency Caching

Before:

COPY . .
RUN npm install
  • Copies all files before installing dependencies, causing cache invalidation whenever any file changes, even if dependencies remain unchanged.
Leverage Dependency Caching
Leverage Dependency Caching 2

After:

COPY package*.json ./
RUN npm install --only=production
COPY . .
  • Copies only package.json and package-lock.json first, ensuring that dependency installation is only re-run when these files change.
  • Installs only production dependencies (–only=production) to exclude devDependencies.
Leverage Dependency Caching 3
Leverage Dependency Caching 4

Result:

  • Faster rebuilds and a smaller image by excluding unnecessary files and dependencies.

4. Remove Global npm Installations

Before:

RUN npm install -g nodemon eslint pm2 typescript prettier
  • Installs global npm packages (nodemon, eslint, pm2, ect.) that are not needed in production, increasing image size.

After:

  • Remove Entirely: Global tools are omitted because they are unnecessary in production.

Result:

  • Reduced image size and eliminated unnecessary layers.

5. Use a Production-Ready CMD

Before:

CMD ["nodemon", "/app/bin/www"]
  • Uses nodemon, which is meant for development, not production.

Result:

  • A streamlined and efficient startup command.

6. Remove Unnecessary Shell Customization

Before:

ENV PS1A="💻\[\e[33m\]\u\[\e[m\]@ubuntu-node\[\e[36m\][\[\e[m\]\[\e[36m\]\w\[\e[m\]\[\e[36m\]]\[\e[m\]: "
RUN echo 'PS1=$PS1A' >> ~/.bashrc
  • Sets and applies a custom shell prompt that has no practical use in production

After:

  • Remove Entirely: Shell customization is unnecessary and is removed.

Result:

  • Cleaner image with no redundant configurations or layers.

Final Optimized Dockerfile

FROM node:20-alpine

WORKDIR /app

RUN apk add --no-cache python3 make g++

COPY package*.json ./
RUN npm install --only=production

COPY . .

EXPOSE 3000

CMD ["node", "/app/bin/www"]

7. Leverage Multi-Stage Builds to Separate Build and Runtime

In many Node.js projects, you might need tools like TypeScript or linters during the build phase—but they’re unnecessary in the final production image. That’s where multi-stage builds come in handy.

Before:

  • Everything—from installation to build to running—happens in a single image, meaning all build-time tools get carried into production.

After:

  • You separate the “build” and “run” stages, keeping only what’s strictly needed at runtime.

Result:

  • Smaller, cleaner production image
  • Build-time dependencies are excluded
  • Faster and safer deployments

Final Optimized Dockerfile

# Stage 1 - Builder
FROM node:20-alpine AS builder

WORKDIR /app

RUN apk add --no-cache python3 make g++

COPY package*.json ./
RUN npm install --only=production

COPY . .

# Stage 2 - Production
FROM node:20-alpine

WORKDIR /app

COPY --from=builder /app/node_modules ./node_modules
COPY --from=builder /app ./

EXPOSE 3000

CMD ["node", "/app/bin/www"]

Bonus. Don’t Forget .dockerignore

Just like .gitignore, the .dockerignore file excludes unnecessary files and folders from the Docker build context (like node_modules, .git, logs, environment files, etc.).

Recommended .dockerignore:

node_modules
.git
*.log
.env
Dockerfile.dev
tests/

Why it matters:

  • Faster builds (Docker doesn’t copy irrelevant files)
  • Smaller and cleaner images
  • Lower risk of leaking sensitive or unnecessary files

Results of Optimization

1. Smaller Image Size:

  • The switch to node:20-alpine and removal of unnecessary packages reduced the image size from 1.36GB, down to 862MB.
Results of Optimization smaller image size

2. Faster Build Times:

  • Leveraging caching for dependency installation speeds up rebuilds significantly.
    • Build No Cache:
      • Ubuntu (Old Dockerfile): ~126.2s
      • Node 20 Alpine (New Dockerfile): 78.4s
    • Rebuild With Cache (After file changes):
      • Ubuntu: 37.1s (Re-run: npm install)
      • Node 20 Alpine: 8.7s (All Cached)
Faster Build Times
Faster Build Times 2

3. Production-Ready Setup:

  • The image now includes only essential build tools and runtime dependencies, making it secure and efficient for production.

By following these changes, your Dockerfile is now lighter, faster, and better suited for production environments. Let me know if you’d like further refinements!

Conclusion

Optimizing your Dockerfile is a crucial step in building smarter, faster, and more efficient containers. By adopting best practices: such as choosing the right base image, simplifying installed packages, leveraging caching, and using production-ready configurations, you can significantly enhance your build process and runtime performance.

In this article, we explored how small, deliberate changes—like switching to node:20-alpine, removing unnecessary tools, and refining dependency management—can lead to.

Solid circle

Sign me up
for the latest news!

Customize software background

Want to customize a software for your business?

Meet with us! Schedule a meeting with us!