Docker Best Practices for Web Apps: Smaller Images, Faster Builds, Safer Containers (Hands-On)
If you’ve ever waited ages for docker build, shipped a 1GB image, or had “works on my machine” creep back in, this guide is for you. We’ll build a production-ready Docker setup for a typical web app (Node.js example), and along the way you’ll learn patterns that apply to almost any stack: Python, PHP, Go, Java, etc.
Our goals:
- Fast builds (good layer caching)
- Small images (multi-stage builds + minimal runtime)
- Secure defaults (non-root user, least privilege)
- Practical dev/prod workflow (Compose, env files, health checks)
1) Start with a Good .dockerignore (Huge Win)
The fastest optimization is often: “don’t send junk to the Docker build context.” A large context slows builds and invalidates cache.
# .dockerignore node_modules dist .build coverage .git .gitignore Dockerfile* docker-compose*.yml npm-debug.log .DS_Store .env
Tip: if you’re using a monorepo, ignore everything you don’t need for the current service. Smaller context = faster builds.
2) Use Multi-Stage Builds (Build vs Runtime)
Multi-stage builds let you compile/build in one stage (with dev tools installed) and run in another stage (minimal dependencies). Result: smaller images and fewer attack surfaces.
Here’s a production Dockerfile for a Node.js web app that builds to dist/:
# Dockerfile # syntax=docker/dockerfile:1 FROM node:20-bookworm-slim AS deps WORKDIR /app # Copy only dependency manifests first (better caching) COPY package.json package-lock.json ./ RUN npm ci FROM node:20-bookworm-slim AS build WORKDIR /app # Reuse node_modules from deps stage COPY --from=deps /app/node_modules ./node_modules COPY . . # Build output into dist/ RUN npm run build FROM node:20-bookworm-slim AS runtime WORKDIR /app ENV NODE_ENV=production # Create a non-root user RUN useradd -m -u 10001 appuser # Copy only what we need at runtime COPY --from=deps /app/node_modules ./node_modules COPY --from=build /app/dist ./dist COPY package.json ./ # If your app listens on 3000 EXPOSE 3000 USER appuser # Start the app CMD ["node", "dist/server.js"]
Why this works well:
COPY package*.jsonbefore copying the full source maximizes cache reuse when only your code changes.npm ciis reproducible for CI/CD and avoids “mystery upgrades.”- Runtime stage copies only
distand runtime deps, not your entire repo. - Runs as a non-root user by default.
3) Don’t Rebuild Dependencies on Every Code Change
Most slow builds happen because the Docker cache gets invalidated too early. Remember: Docker layers are cached in order. If you copy your entire project before installing dependencies, any code change invalidates the dependency layer.
Bad pattern:
COPY . . RUN npm install
Better pattern:
COPY package.json package-lock.json ./ RUN npm ci COPY . .
This same idea applies to Python (requirements.txt) and PHP (composer.json).
4) Add a Health Check (So Orchestrators Can Help You)
Health checks let Docker (and later Kubernetes) know if the container is actually healthy, not just “running.” Here’s a simple HTTP health check:
# Add to the runtime stage in Dockerfile HEALTHCHECK --interval=30s --timeout=3s --start-period=10s --retries=3 \ CMD node -e "fetch('http://localhost:3000/health').then(r=>process.exit(r.ok?0:1)).catch(()=>process.exit(1))"
And expose a tiny endpoint in your app (pseudo-code):
// /health handler res.status(200).json({ status: "ok" });
In real deployments, you might check DB connectivity too—but keep it fast to avoid self-inflicted outages.
5) Use Docker Compose for Local Development (Without “Snowflake” Machines)
Compose is perfect for local multi-service setups: web app + database + cache. Here’s a dev-friendly docker-compose.yml for Node + Postgres:
version: "3.9" services: web: build: context: . dockerfile: Dockerfile target: build command: npm run dev ports: - "3000:3000" environment: DATABASE_URL: postgres://app:app@db:5432/appdb volumes: - ./:/app - /app/node_modules depends_on: - db db: image: postgres:16 environment: POSTGRES_USER: app POSTGRES_PASSWORD: app POSTGRES_DB: appdb ports: - "5432:5432" volumes: - pgdata:/var/lib/postgresql/data volumes: pgdata:
Notes:
- We build using
target: buildso the container has tooling for hot reload. - The
volumessetup mounts your code into the container for instant edits. - /app/node_modulesprevents your host from overwriting container deps.
Run it:
docker compose up --build
6) Separate Dev and Production Settings Cleanly
A common anti-pattern is one “mega Compose file” with conditional hacks. A simple approach is a base file plus an override.
Base docker-compose.yml (production-ish):
version: "3.9" services: web: image: myapp-web:latest ports: - "3000:3000" environment: NODE_ENV: production
Dev override docker-compose.override.yml (applies automatically):
services: web: build: context: . dockerfile: Dockerfile target: build command: npm run dev volumes: - ./:/app - /app/node_modules environment: NODE_ENV: development
This keeps production simple and makes dev convenient.
7) Handle Secrets Safely (Don’t Bake Them Into Images)
Never put secrets in your Dockerfile via ENV or copy .env into the image. Images get pushed, cached, and shared.
- For local dev, use
--env-fileor Composeenvironment. - For production, inject secrets via your platform (CI/CD secrets, container runtime secrets, vaults).
Example: run with an env file locally:
docker run --env-file .env -p 3000:3000 myapp-web:latest
8) Keep Images Lean: Pick the Right Base and Remove Build Tools
Smaller images download faster and have fewer vulnerabilities. Tactics:
- Use
slimvariants when possible (likebookworm-slim). - Multi-stage builds: keep compilers/build tools out of runtime images.
- Copy only runtime artifacts (e.g.,
dist/), not the full repo.
If you’re building static assets (React/Vite/etc.), you can use an even smaller runtime like Nginx:
# Dockerfile for static front-end FROM node:20-bookworm-slim AS build WORKDIR /app COPY package.json package-lock.json ./ RUN npm ci COPY . . RUN npm run build FROM nginx:1.27-alpine AS runtime COPY --from=build /app/dist /usr/share/nginx/html EXPOSE 80
9) Common Pitfalls Checklist
- Cache busting too early: copying all files before installing deps
- Bloated context: missing
.dockerignore - Running as root: no
USERin runtime stage - Leaking secrets: copying
.envor usingENVfor credentials - One container does everything: mixing web + DB in a single container
- No health check: orchestrators can’t detect “half-dead” apps
10) Quick “Production Build” Commands
Build an optimized image:
docker build -t myapp-web:latest .
Run it:
docker run -p 3000:3000 --rm myapp-web:latest
Check health status:
docker ps docker inspect --format='{{json .State.Health}}' <container_id>
Wrap-Up
If you adopt just three habits—.dockerignore, multi-stage builds, and correct layer ordering—you’ll feel the difference immediately: faster builds, smaller images, fewer surprises. Add non-root runtime users and safer secret handling, and you’ve got a setup you can confidently ship and maintain.
If you want, tell me your stack (Node/Python/PHP/etc.) and whether you deploy to a VM, Kubernetes, or a PaaS, and I’ll tailor the Dockerfile + Compose layout to your exact app.
Leave a Reply