A well-written Dockerfile is the difference between a 1.2GB image that takes five minutes to build and a 50MB image that builds in seconds. This guide covers the practices that actually matter — multi-stage builds, layer caching, security, and the mistakes that silently bloat your containers.
Start with the Right Base Image
Your base image determines your starting size and attack surface. Choose the smallest image that has what you need:
- Alpine variants (
node:22-alpine,python:3.13-alpine) — ~5MB base. Great when it works, but musl libc can cause issues with some native modules. - Slim variants (
node:22-slim,python:3.13-slim) — ~80MB base. Debian-based, fewer compatibility issues than Alpine. - Distroless (
gcr.io/distroless/nodejs22) — no shell, no package manager. Minimal attack surface for production.
Always pin a specific version. FROM node:latest today might break your build tomorrow.
Multi-Stage Builds
Multi-stage builds are the single most impactful optimization. Build in one stage, copy only what you need to a minimal runtime stage:
# Stage 1: Build
FROM node:22-alpine AS builder
WORKDIR /app
COPY package*.json ./
RUN npm ci
COPY . .
RUN npm run build
# Stage 2: Production
FROM node:22-alpine AS runner
WORKDIR /app
ENV NODE_ENV=production
# Copy only what's needed to run
COPY --from=builder /app/package*.json ./
COPY --from=builder /app/node_modules ./node_modules
COPY --from=builder /app/dist ./dist
EXPOSE 3000
CMD ["node", "dist/index.js"]The final image doesn't include source code, devDependencies, or build tools. For a typical Node.js app, this can cut image size by 60-80%.
Layer Caching: Order Matters
Docker caches each layer. When a layer changes, all subsequent layers are rebuilt. Put things that change least first:
# GOOD: Dependencies change less often than source code
COPY package*.json ./
RUN npm ci
COPY . .
# BAD: Copying everything first busts the cache every time
COPY . .
RUN npm ciWith the good order, changing a source file won't re-download all your npm packages. The npm ci layer stays cached.
Use .dockerignore
Without a .dockerignore, COPY . . sends everything to the Docker daemon — including node_modules, .git, and test files:
node_modules
.git
.gitignore
*.md
Dockerfile
docker-compose*.yml
.env*
.DS_Store
coverage
.nyc_output
distThis speeds up build context transfer and prevents accidentally leaking secrets into the image.
Security Best Practices
Don't run as root
Containers run as root by default. If an attacker escapes the application, they have root access to the container:
# Create a non-root user
RUN addgroup --system appgroup && \
adduser --system --ingroup appgroup appuser
# Copy files with correct ownership
COPY --from=builder --chown=appuser:appgroup /app ./
# Switch to non-root
USER appuser
CMD ["node", "dist/index.js"]Never put secrets in the image
Even if you delete a secret in a later layer, it's still visible in the image history. Use build-time secrets or runtime environment variables:
# BAD: Secret is baked into a layer
COPY .env .
RUN source .env && npm run build
# GOOD: Use Docker BuildKit secrets
RUN --mount=type=secret,id=env,target=.env \
source .env && npm run buildScan for vulnerabilities
Run docker scout quickview or trivy image myapp:latest to check for known CVEs in your base image and installed packages. Do this in CI, not just locally.
Reduce Layer Count
Each RUN, COPY, and ADD instruction creates a new layer. Combine related commands:
# BAD: 3 layers
RUN apt-get update
RUN apt-get install -y curl
RUN rm -rf /var/lib/apt/lists/*
# GOOD: 1 layer, and cleanup in the same layer
RUN apt-get update && \
apt-get install -y --no-install-recommends curl && \
rm -rf /var/lib/apt/lists/*The --no-install-recommends flag prevents apt from pulling in packages you don't need. Cleaning up in the same RUN removes the apt cache from the layer entirely.
Health Checks
Add a HEALTHCHECK so Docker (and orchestrators like Kubernetes) know when your container is actually ready:
HEALTHCHECK --interval=30s --timeout=3s --retries=3 \
CMD wget --no-verbose --tries=1 --spider http://localhost:3000/health || exit 1Common Mistakes
| Mistake | Fix |
|---|---|
Using :latest tag | Pin specific version (node:22.5-alpine) |
npm install in production | Use npm ci for deterministic installs |
No .dockerignore | Always create one to exclude .git, node_modules, etc. |
| Copying secrets into the image | Use BuildKit secrets or runtime env vars |
| Running as root | Add USER instruction |
| Installing dev dependencies in production | Use multi-stage builds or npm ci --omit=dev |
Ready to deploy?
Once your images are optimized, DigitalOcean App Platform can deploy your containers directly from a Dockerfile or Docker Hub image — no Kubernetes or server config needed. Free tier available.
Validate Your Dockerfiles
Use our Dockerfile Validator to check for syntax errors, security issues, and best practices violations. For multi-container setups, try the Docker Compose Validator. Both tools run entirely in your browser.