Article

Mastering Dockerfile Best Practices: A Complete Guide to Container Excellence

6 min read
Originally published on Medium. Read, clap or comment on Medium ↗

Building efficient, secure, and maintainable Docker images that your future self will thank you for

Docker has revolutionized how we build, ship, and run applications. But here’s the thing: writing a Dockerfile that works is easy. Writing one that works well is an art form that can make the difference between a smooth deployment and a production nightmare.

After years of building containerized applications and seeing countless Dockerfiles in the wild, I’ve compiled the essential best practices that separate amateur Docker users from the pros. Whether you’re just starting your containerization journey or looking to optimize your existing images, this guide will transform how you approach Dockerfile creation.

Why Dockerfile Quality Matters More Than You Think

Before diving into the practices, let’s establish why this matters. A poorly written Dockerfile can lead to:

The good news? Most of these issues are preventable with the right approach.

1. Choose Your Base Image Wisely

Your base image is the foundation of everything that follows. This decision ripples through your entire application lifecycle.

Prefer Official Images

Always start with official images from Docker Hub when available. They’re maintained by the software vendors, receive regular security updates, and follow Docker best practices.

# Good: Official Node.js image
FROM node:18-alpine
# Avoid: Random community image
FROM someuser/nodejs-custom

Embrace Alpine Linux

Alpine Linux images are incredibly small (around 5MB) and security-focused. They use musl libc instead of glibc, which significantly reduces the attack surface.

# Standard Ubuntu-based image (~200MB)
FROM node:18
# Alpine-based image (~50MB)
FROM node:18-alpine

Pin Your Versions

Never use latest in production. It's a moving target that can break your builds unexpectedly.

# Dangerous: Version will change over time
FROM node:latest
# Better: Specific version
FROM node:18# Best: Specific version with SHA
FROM node:18-alpine@sha256:f77a1aef2da8d83e45ec990f45df906f9021c7d12455cc64a8365c65b154c158

2. Master the Art of Layer Optimization

Docker images are built in layers, and understanding this concept is crucial for optimization.

Minimize Layer Count

Each RUN, COPY, and ADD instruction creates a new layer. Combine related commands to reduce layers:

# Inefficient: Multiple layers
RUN apt-get update
RUN apt-get install -y curl
RUN apt-get install -y git
RUN apt-get clean
# Efficient: Single layer
RUN apt-get update && \
apt-get install -y curl git && \
apt-get clean && \
rm -rf /var/lib/apt/lists/*

Leverage Build Cache

Order your instructions from least to most frequently changing. This maximizes cache utilization:

# Good: Dependencies change less frequently than source code
COPY package*.json ./
RUN npm ci --only=production
COPY . .

Use Multi-stage Builds

Multi-stage builds are a game-changer for creating lean production images:

# Build stage
FROM node:18-alpine AS builder
WORKDIR /app
COPY package*.json ./
RUN npm ci
COPY . .
RUN npm run build
# Production stage
FROM node:18-alpine AS production
WORKDIR /app
COPY package*.json ./
RUN npm ci --only=production && npm cache clean --force
COPY --from=builder /app/dist ./dist
USER node
EXPOSE 3000
CMD ["node", "dist/server.js"]

3. Implement Rock-Solid Security Practices

Security should be baked into your Dockerfile from day one, not added as an afterthought.

Never Run as Root

Running containers as root is a security anti-pattern. Create and use a non-root user:

# Create a non-root user
RUN addgroup -g 1001 -S nodejs && \
adduser -S nextjs -u 1001
# Switch to the non-root user
USER nextjs

Keep Images Updated

Regularly rebuild images to incorporate security patches:

# Update packages during build
RUN apt-get update && \
apt-get upgrade -y && \
apt-get clean && \
rm -rf /var/lib/apt/lists/*

Scan for Vulnerabilities

Use tools like docker scan or integrate security scanning into your CI/CD pipeline:

# Scan your image for vulnerabilities
docker scan myapp:latest

4. Perfect Your File Management

How you handle files can dramatically impact both image size and build performance.

Use .dockerignore

Just like .gitignore, .dockerignore prevents unnecessary files from being sent to the Docker build context:

node_modules
npm-debug.log
.git
.gitignore
README.md
.env
coverage
.nyc_output

Optimize COPY Instructions

Be specific about what you copy and when:

# Copy dependency files first (better caching)
COPY package*.json ./
RUN npm ci --only=production
# Copy source code last
COPY src/ ./src/

Clean Up in the Same Layer

Always clean up temporary files in the same RUN instruction that creates them:

RUN apt-get update && \
apt-get install -y build-essential && \
# ... build steps ... && \
apt-get remove -y build-essential && \
apt-get autoremove -y && \
rm -rf /var/lib/apt/lists/*

5. Configure Your Runtime Environment

Proper environment configuration ensures your containers run reliably across different environments.

Set Explicit Environment Variables

Make your configuration explicit and documentable:

ENV NODE_ENV=production \
PORT=3000 \
USER=node
# Use environment variables in your commands
EXPOSE $PORT
USER $USER

Use HEALTHCHECK

Health checks help orchestrators understand if your application is ready to serve traffic:

HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \
CMD curl -f http://localhost:$PORT/health || exit 1

Choose the Right Process Manager

Use exec form for CMD to ensure proper signal handling:

# Preferred: exec form
CMD ["node", "server.js"]
# Avoid: shell form (doesn't handle signals properly)
CMD node server.js

6. Advanced Optimization Techniques

Once you’ve mastered the basics, these advanced techniques can take your Dockerfiles to the next level.

Use BuildKit Features

Enable BuildKit for advanced build features:

# syntax=docker/dockerfile:1.4
FROM node:18-alpine# Use secret mounts for sensitive data
RUN --mount=type=secret,id=npmrc,target=/root/.npmrc \
npm install# Use cache mounts for package managers
RUN --mount=type=cache,target=/root/.npm \
npm install

Implement Proper Signal Handling

Ensure your application handles termination signals gracefully:

# Use tini as init system for proper signal handling
RUN apk add --no-cache tini
ENTRYPOINT ["tini", "--"]
CMD ["node", "server.js"]

7. Testing and Validation

A great Dockerfile includes mechanisms for testing and validation.

Structure Tests

Use tools like Container Structure Tests to validate your images:

# container-structure-test.yaml
schemaVersion: '2.0.0'
commandTests:
- name: 'node version'
command: 'node'
args: ['--version']
expectedOutput: ['v18.*']

Integration Testing

Test your containers in an environment that mirrors production:

# Test stage
FROM production AS test
RUN npm run test

The Complete Example

Here’s a production-ready Dockerfile that incorporates all these best practices:

# syntax=docker/dockerfile:1.4
FROM node:18-alpine@sha256:f77a1aef2da8d83e45ec990f45df906f9021c7d12455cc64a8365c65b154c158 AS base
# Install security updates
RUN apk upgrade --no-cache# Create app directory and user
WORKDIR /app
RUN addgroup -g 1001 -S nodejs && \
adduser -S nextjs -u 1001# Install dependencies
FROM base AS deps
COPY package*.json ./
RUN --mount=type=cache,target=/root/.npm \
npm ci --only=production && npm cache clean --force# Build stage
FROM base AS builder
COPY package*.json ./
RUN --mount=type=cache,target=/root/.npm \
npm ci
COPY . .
RUN npm run build# Production stage
FROM base AS production
ENV NODE_ENV=production \
PORT=3000# Install tini for proper signal handling
RUN apk add --no-cache tini# Copy built application
COPY --from=deps --chown=nextjs:nodejs /app/node_modules ./node_modules
COPY --from=builder --chown=nextjs:nodejs /app/dist ./dist
COPY --chown=nextjs:nodejs package*.json ./# Health check
HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \
CMD wget --no-verbose --tries=1 --spider http://localhost:$PORT/health || exit 1# Switch to non-root user
USER nextjsEXPOSE $PORTENTRYPOINT ["tini", "--"]
CMD ["node", "dist/server.js"]

Conclusion: Building for the Future

Writing excellent Dockerfiles is about more than just getting your application to run in a container. It’s about creating reliable, secure, and maintainable infrastructure that scales with your team and organization.

The practices outlined in this guide will help you:

Remember, the best Dockerfile is one that your team can understand, maintain, and improve over time. Start with these fundamentals, and gradually incorporate the advanced techniques as your containerization journey evolves.

The investment in learning these practices pays dividends immediately and compounds over time. Your future self — and your teammates — will thank you for taking the time to do it right.


What Dockerfile practices have made the biggest impact in your projects? Share your experiences in the comments below, and let’s continue building better containers together.

← All articles Discuss on Medium ↗