Why Custom Docker Images Matter
Docker Hub hosts millions of pre-built images, but production applications need more than a generic base image. You need your specific runtime version, your dependencies pre-installed, your configuration baked in, and your security policies enforced. Custom Docker images give you complete control over what runs inside your containers, from the operating system layer all the way up to your application code.
Building good Docker images is both an art and a science. A poorly built image can be gigabytes in size, take minutes to build, contain security vulnerabilities, and leak sensitive data. A well-built image is compact, builds in seconds with caching, runs as a non-root user, and includes nothing beyond what your application needs. This guide covers everything you need to know to build production-grade Docker images.
Dockerfile Instruction Reference
Before diving into best practices, let us review every Dockerfile instruction you will use. Understanding what each instruction does and how it affects the build cache and final image is essential.
| Instruction | Purpose | Cache Impact |
|---|---|---|
FROM | Sets the base image | Creates a new build stage |
RUN | Executes commands during build | Creates a new layer |
COPY | Copies files from host to image | Invalidates cache on file change |
ADD | Like COPY but with URL/tar support | Prefer COPY unless you need extras |
CMD | Default command when container starts | Only one per Dockerfile |
ENTRYPOINT | Fixed executable for the container | CMD becomes arguments to ENTRYPOINT |
ENV | Sets environment variables | Persists in running container |
ARG | Build-time variables | Not available in running container |
WORKDIR | Sets working directory | Creates dir if it does not exist |
USER | Sets the user for subsequent instructions | Security best practice |
EXPOSE | Documents which ports the container listens on | Metadata only, does not publish |
VOLUME | Creates a mount point for persistent data | Data persists across container lifecycle |
HEALTHCHECK | Defines how to test if the container is healthy | Used by orchestrators for routing |
LABEL | Adds metadata to the image | OCI annotations, maintainer info |
Your First Dockerfile
Let us start with a simple Dockerfile for a Node.js application and then progressively improve it:
FROM node:20
WORKDIR /app
COPY package*.json ./
RUN npm install
COPY . .
EXPOSE 3000
CMD ["node", "server.js"]
This works, but it has several problems: the image is over 1GB (because node:20 includes the full Debian OS), it runs as root, it includes development dependencies, and changing any source file invalidates the npm install cache. Let us fix each of these issues.
Choosing the Right Base Image
The base image you choose has a massive impact on your final image size, security posture, and build time. Here is how the common base image variants compare:
| Base Image | Size | Includes | Best For |
|---|---|---|---|
node:20 | ~1.1 GB | Full Debian + build tools | Development, building native modules |
node:20-slim | ~240 MB | Minimal Debian | Apps without native dependencies |
node:20-alpine | ~140 MB | Alpine Linux + musl libc | Smallest possible image |
gcr.io/distroless/nodejs20 | ~130 MB | Node.js runtime only, no shell | Maximum security |
Multi-Stage Builds: The Most Important Optimization
Multi-stage builds are the single most impactful technique for reducing Docker image size. The idea is simple: use one stage with full build tools to compile your application, then copy only the output to a minimal runtime stage.
FROM node:20-alpine AS builder
WORKDIR /app
COPY package*.json ./
RUN npm ci --only=production
# Copy production deps aside
RUN cp -R node_modules /prod_modules
# Install all deps (including devDependencies) for build
RUN npm ci
COPY . .
RUN npm run build
# Stage 2: Production
FROM node:20-alpine AS production
WORKDIR /app
# Non-root user
RUN addgroup -g 1001 appgroup && \
adduser -u 1001 -G appgroup -s /bin/sh -D appuser
# Copy only what we need
COPY --from=builder /prod_modules ./node_modules
COPY --from=builder /app/dist ./dist
COPY --from=builder /app/package.json ./
USER appuser
EXPOSE 3000
HEALTHCHECK --interval=30s --timeout=3s --start-period=5s \
CMD wget --no-verbose --tries=1 --spider http://localhost:3000/health || exit 1
CMD ["node", "dist/server.js"]
Layer Caching: Order Matters
Docker builds images layer by layer. When a layer changes, all subsequent layers are rebuilt. This means the order of your Dockerfile instructions directly impacts build speed.
COPY . . Any file change invalidates npm install cache
RUN npm install
# GOOD: Copy only package files first
COPY package*.json ./ Only invalidated when deps change
RUN npm install
COPY . . Source code changes do not rebuild deps
The .dockerignore File
Just like .gitignore, the .dockerignore file prevents unnecessary files from being sent to the Docker daemon during builds. Without it, Docker sends your entire project directory (including node_modules, .git, and build artifacts) as the build context.
node_modules
npm-debug.log
.git
.gitignore
Dockerfile
.dockerignore
docker-compose*.yml
.env*
*.md
tests/
coverage/
dist/
.vscode/
.idea/
Reducing Image Size
Every megabyte matters when you are pulling images across networks, storing them in registries, and spinning up containers. Here are the most effective techniques for reducing image size:
Combine RUN Instructions
Each RUN instruction creates a new layer. Even if you delete files in a later RUN, the deleted files still exist in the previous layer. Combine related commands into a single RUN instruction.
RUN apt-get update
RUN apt-get install -y curl wget
RUN apt-get clean
# GOOD: Single layer, cache cleaned in same layer
RUN apt-get update && \
apt-get install -y --no-install-recommends curl wget && \
apt-get clean && apt-get autoremove -y
Use --no-install-recommends
Debian and Ubuntu apt installs recommended packages by default, which can add hundreds of megabytes of unnecessary software. Always use --no-install-recommends and clean the apt cache in the same layer.
ENTRYPOINT vs CMD
Understanding the difference between ENTRYPOINT and CMD is crucial for building flexible, composable images.
| Feature | ENTRYPOINT | CMD |
|---|---|---|
| Purpose | Defines the executable | Provides default arguments |
| Override at runtime | Requires --entrypoint | Simply append to docker run |
| When both are set | ENTRYPOINT runs | CMD becomes arguments to ENTRYPOINT |
| Best for | Containers that are a single tool | Containers with flexible defaults |
ENTRYPOINT ["node"]
CMD ["server.js"]
# docker run myapp = node server.js
# docker run myapp worker.js = node worker.js
# docker run myapp --version = node --version
Security Hardening
Run as Non-Root User
By default, Docker containers run as root. If an attacker escapes the container, they have root access to the host. Always create and switch to a non-root user.
RUN addgroup -g 1001 appgroup && \
adduser -u 1001 -G appgroup -s /bin/sh -D appuser
# For Debian/Ubuntu-based images
RUN groupadd -r appgroup && \
useradd -r -g appgroup -s /sbin/nologin appuser
# Set ownership and switch user
COPY --chown=appuser:appgroup . /app
USER appuser
docker history. Use Docker secrets, bind mounts, or runtime environment variables instead.
HEALTHCHECK Instruction
The HEALTHCHECK instruction tells Docker how to determine if your container is functioning correctly. Orchestrators like Docker Swarm and Kubernetes use this to route traffic and restart unhealthy containers.
CMD curl -f http://localhost:3000/health || exit 1
# For Alpine (no curl by default, use wget)
HEALTHCHECK --interval=30s --timeout=3s --retries=3 \
CMD wget --no-verbose --tries=1 --spider http://localhost:3000/health || exit 1
Real-World Example: PHP Laravel Application
FROM composer:2 AS vendor
WORKDIR /app
COPY composer.json composer.lock ./
RUN composer install --no-dev --no-scripts --no-interaction --optimize-autoloader
# Stage 2: Frontend assets
FROM node:20-alpine AS frontend
WORKDIR /app
COPY package*.json ./
RUN npm ci
COPY resources/ resources/
COPY vite.config.js ./
RUN npm run build
# Stage 3: Production image
FROM php:8.3-fpm-alpine
WORKDIR /var/www/html
# Install PHP extensions
RUN apk add --no-cache libpng-dev libjpeg-turbo-dev && \
docker-php-ext-configure gd --with-jpeg && \
docker-php-ext-install pdo_mysql gd opcache && \
apk del libpng-dev libjpeg-turbo-dev
# Copy application code
COPY . .
COPY --from=vendor /app/vendor ./vendor
COPY --from=frontend /app/public/build ./public/build
# Configure permissions
RUN chown -R www-data:www-data storage bootstrap/cache && \
chmod -R 775 storage bootstrap/cache
USER www-data
EXPOSE 9000
CMD ["php-fpm"]
Real-World Example: Go Application
Go applications benefit enormously from multi-stage builds because Go produces statically linked binaries that need no runtime dependencies:
FROM golang:1.24-alpine AS builder
WORKDIR /src
COPY go.mod go.sum ./
RUN go mod download
COPY . .
RUN CGO_ENABLED=0 GOOS=linux go build -ldflags="-s -w" -o /app/server ./cmd/server/
# Stage 2: Minimal runtime (scratch = empty image)
FROM scratch
COPY --from=builder /etc/ssl/certs/ca-certificates.crt /etc/ssl/certs/
COPY --from=builder /app/server /server
EXPOSE 8080
ENTRYPOINT ["/server"]
golang:1.24 as the final image would be ~800MB. With alpine it is ~15MB. With scratch it is just your binary size — typically 5-20MB. That is a 40-160x size reduction!
Image Labeling with OCI Annotations
LABEL org.opencontainers.image.description="A web application"
LABEL org.opencontainers.image.version="1.0.0"
LABEL org.opencontainers.image.authors="[email protected]"
LABEL org.opencontainers.image.source="https://github.com/org/repo"
LABEL org.opencontainers.image.created="2026-01-01T00:00:00Z"
Image Scanning
Before pushing an image to production, scan it for known vulnerabilities:
$ docker scout cves myapp:latest
# Using Trivy (open source)
$ trivy image myapp:latest
myapp:latest (alpine 3.19)
Total: 0 (UNKNOWN: 0, LOW: 0, MEDIUM: 0, HIGH: 0, CRITICAL: 0)
No vulnerabilities found!
Build and Push Checklist
- Multi-stage build separates build tools from runtime
- Base image is the smallest viable option (alpine, slim, or distroless)
- Non-root user is configured with USER instruction
- .dockerignore excludes unnecessary files
- RUN instructions are combined to minimize layers
- Package manager cache is cleaned in the same RUN layer
- COPY instructions are ordered for optimal cache usage
- HEALTHCHECK is defined for orchestrator integration
- No secrets are embedded in the image
- Image is scanned for vulnerabilities before pushing
Conclusion
Building optimized Docker images is a skill that pays dividends on every deployment. Smaller images download faster, start quicker, use less storage, and present a smaller attack surface. The techniques covered here — multi-stage builds, careful layer ordering, minimal base images, and non-root users — are not optional niceties but production essentials.
Start with a simple Dockerfile that works, then iteratively apply these optimizations. Use docker history and docker image ls to measure the impact of each change. The goal is not to achieve the absolute smallest image possible, but to find the right balance between image size, build time, and maintainability for your specific application. A well-built Docker image is one you can build, scan, and deploy with confidence.