Docker Best Practices for Web Developers: Build Smaller Images, Faster Deploys, Fewer “It Works on My Machine” Bugs

Docker Best Practices for Web Developers: Build Smaller Images, Faster Deploys, Fewer “It Works on My Machine” Bugs

Docker is one of those tools that feels easy on day one (“it runs!”) and then quietly becomes the reason your builds take 12 minutes, your images are 1.8GB, and production behaves differently than your laptop. This hands-on guide focuses on practical Docker habits for junior/mid web developers: building smaller images, keeping secrets safe, speeding up CI, and making containers reliable in production.

1) Start With a .dockerignore (It’s Free Performance)

Docker sends a “build context” (your folder) to the daemon. If your context includes node_modules, logs, or local caches, builds get slow and images can accidentally include junk. Add a .dockerignore early.

# .dockerignore node_modules dist build .cache .pytest_cache __pycache__ *.log .env .git .gitignore .DS_Store 

Rule of thumb: ignore anything that can be re-created (dependencies, build artifacts) and anything sensitive (.env).

2) Use Multi-Stage Builds to Keep Runtime Images Small

Multi-stage builds let you compile/build in one stage and copy only what you need into the final runtime stage. This keeps production images slim and reduces attack surface.

Example: Node.js (Express) With Multi-Stage Build

# Dockerfile # --- deps stage: install dependencies with caching --- FROM node:20-alpine AS deps WORKDIR /app COPY package.json package-lock.json ./ RUN npm ci # --- build stage: compile/transpile assets --- FROM node:20-alpine AS build WORKDIR /app COPY --from=deps /app/node_modules ./node_modules COPY . . RUN npm run build # --- runtime stage: only what we need to run --- FROM node:20-alpine AS runner WORKDIR /app ENV NODE_ENV=production # Copy production deps only (optional improvement) COPY package.json package-lock.json ./ RUN npm ci --omit=dev # Copy built output (adjust paths to your app) COPY --from=build /app/dist ./dist # Security: run as non-root user USER node EXPOSE 3000 CMD ["node", "dist/server.js"] 

Why this matters:

  • npm ci is reproducible (good for CI/CD).
  • Final image doesn’t include your source tree, tests, or build tools.
  • Smaller images push/pull faster and reduce cold start time.

3) Make the Most of Layer Caching (Order Matters)

Docker caches layers. If you copy your whole project before installing dependencies, you’ll invalidate the cache every time you change a single file. A better pattern is:

  • Copy only dependency manifests first (e.g., package.json, requirements.txt).
  • Install dependencies.
  • Copy the rest of your source.

This is why the Node Dockerfile copies package.json first and runs npm ci before COPY . ..

4) Pin Versions (Don’t Let “Latest” Break You)

Using FROM node:latest works until it doesn’t. Pin major versions at minimum (and ideally minor versions if you need strict reproducibility).

  • Good: FROM node:20-alpine
  • Better (more strict): FROM node:20.11-alpine

Same idea for OS packages (Alpine vs Debian) and language runtimes.

5) Don’t Bake Secrets into Images

Never copy secrets (API keys, DB passwords, private certs) into an image. Images get cached, pushed, and shared. Use environment variables at runtime or secret managers.

For local development with Docker Compose, you can use an .env file (but keep it out of git and out of images):

# compose.yaml services: web: build: . ports: - "3000:3000" environment: - DATABASE_URL=${DATABASE_URL} 

And make sure .env is in .dockerignore and .gitignore.

6) Add a Healthcheck (So Orchestrators Can Help You)

A container can be “running” while your app is stuck, misconfigured, or unable to reach dependencies. Healthchecks let Docker (and platforms like Kubernetes) detect this.

# Dockerfile (runtime stage) HEALTHCHECK --interval=30s --timeout=3s --start-period=10s --retries=3 \ CMD wget -qO- http://localhost:3000/health || exit 1 

In your app, expose a lightweight endpoint like /health that checks minimal things (often “process alive” is enough; deeper checks can cause false negatives).

7) Run as a Non-Root User

Many base images default to root, which is risky. Prefer images that include a non-root user (like node on official Node images) or create one.

# Example (Debian-based image) RUN useradd -m appuser USER appuser 

This won’t fix all security issues, but it’s a solid baseline.

8) Use a Minimal Base Image (But Don’t Over-Optimize)

alpine images are small, but occasionally cause headaches (native dependencies, glibc differences). Debian slim images are a common middle ground.

  • Small and common: node:20-alpine
  • Often simpler compatibility: python:3.12-slim, node:20-slim

Pick what your team can maintain. “Smallest possible” is not always “best.”

9) Practical Compose Setup: App + Postgres

Here’s a minimal compose.yaml for local development. It’s not production-ready, but it’s a clean starting point.

services: db: image: postgres:16 environment: POSTGRES_USER: app POSTGRES_PASSWORD: app POSTGRES_DB: appdb ports: - "5432:5432" volumes: - pgdata:/var/lib/postgresql/data web: build: . depends_on: - db environment: DATABASE_URL: postgres://app:app@db:5432/appdb ports: - "3000:3000" volumes: pgdata: 

Tip: In production, you usually don’t run the database in the same Compose project as the app. Use managed DBs or dedicated infrastructure.

10) Speed Up Builds in CI with BuildKit and Cache

When CI is slow, the common culprits are: huge contexts, poor layer ordering, and no cache reuse. Turn on BuildKit and use cache mounts for package managers where possible.

Example: caching npm using BuildKit syntax:

# syntax=docker/dockerfile:1.5 FROM node:20-alpine AS deps WORKDIR /app COPY package.json package-lock.json ./ RUN --mount=type=cache,target=/root/.npm \ npm ci 

This keeps the npm cache across builds on the same builder (useful in CI systems that support persistent caching).

11) A Production-Friendly Pattern: One Process Per Container

A common anti-pattern is running multiple unrelated processes in one container (web server + worker + cron). It’s harder to scale and harder to debug. Prefer one main process per container and scale services independently.

  • web container: serves HTTP traffic
  • worker container: background jobs
  • scheduler container: cron-like tasks

In Compose, that might look like separate services with the same image but different CMD:

services: web: build: . command: ["node", "dist/server.js"] worker: build: . command: ["node", "dist/worker.js"] 

12) Debugging: Common “Why Is Docker Doing This?” Fixes

  • Builds are slow: add .dockerignore, reorder layers, reduce context size, use multi-stage builds.
  • Cache never hits: ensure dependency files are copied before source; don’t invalidate layers unnecessarily.
  • App can’t reach DB: inside Compose, use the service name as hostname (e.g., db), not localhost.
  • Permissions issues: avoid writing to root-owned paths; run as non-root; ensure volume mounts have correct permissions.
  • Works locally, fails in prod: pin base image versions, avoid relying on implicit OS packages, log config at startup.

Quick Checklist You Can Apply Today

  • ✅ Add .dockerignore to shrink context.
  • ✅ Use multi-stage builds to keep runtime images small.
  • ✅ Copy dependency manifests first for better cache hits.
  • ✅ Pin base image versions (avoid latest).
  • ✅ Don’t bake secrets into images.
  • ✅ Add healthchecks and run as non-root.
  • ✅ Use Compose for dev, but separate concerns for production.

If you adopt just the first three items (ignore file + multi-stage + correct layer order), you’ll usually cut image sizes dramatically and speed up CI builds without changing your app code.


Leave a Reply

Your email address will not be published. Required fields are marked *