Docker Best Practices for Web Developers: Faster Builds, Smaller Images, Safer Containers (Hands-On)

Docker Best Practices for Web Developers: Faster Builds, Smaller Images, Safer Containers (Hands-On)

Docker is easy to “get working” and surprisingly easy to “get slow, huge, and fragile.” This guide shows a practical, repeatable setup you can apply to most web apps (Node, Python, PHP—same principles). You’ll build images that are smaller, rebuild faster, and run more safely in production, while keeping local development convenient.

1) Start with a good .dockerignore

Most bloated images and slow builds begin with copying too much into the build context. A solid .dockerignore speeds up builds and prevents leaking secrets into images.

# .dockerignore .git node_modules dist build coverage *.log .env .env.* *.pem *.key .DS_Store .idea .vscode # Python __pycache__ *.pyc .venv # PHP/Laravel vendor storage/*.log 

Rule of thumb: if the app can regenerate it, don’t send it to Docker.

2) Use multi-stage builds (build deps ≠ runtime deps)

Multi-stage builds let you compile/build in one stage and copy only the artifacts into a clean runtime stage. This is the single biggest win for image size and security.

Example: a simple Node.js web app (works similarly for front-end builds, SSR, etc.).

# Dockerfile # syntax=docker/dockerfile:1.6 FROM node:20-alpine AS deps WORKDIR /app # Copy only dependency manifests first (enables layer caching) COPY package.json package-lock.json ./ RUN npm ci FROM node:20-alpine AS build WORKDIR /app COPY --from=deps /app/node_modules ./node_modules COPY . . RUN npm run build FROM node:20-alpine AS runtime WORKDIR /app ENV NODE_ENV=production # Create a non-root user RUN addgroup -S app && adduser -S app -G app # Copy only what you need at runtime COPY --from=build /app/package.json ./ COPY --from=build /app/dist ./dist # If your runtime needs node_modules, install prod-only dependencies: COPY package-lock.json ./ RUN npm ci --omit=dev && npm cache clean --force USER app EXPOSE 3000 # Healthcheck (optional but recommended) 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))" CMD ["node", "dist/server.js"] 
  • Why it’s fast: dependency layers cache well because you copy package*.json first.

  • Why it’s small: the runtime stage doesn’t include source files, build tools, or dev dependencies.

  • Why it’s safer: it runs as a non-root user.

3) Don’t copy everything early: maximize cache hits

Docker caches layers. If you copy your entire repo before installing dependencies, any code change invalidates the cache and forces reinstalling dependencies.

Prefer this pattern:

  • Copy dependency manifests (package.json, requirements.txt, composer.json)

  • Install dependencies

  • Copy the rest of the source

  • Build

This keeps rebuilds quick when you edit code.

4) Use a separate dev setup with bind mounts

Production images should be immutable. Development should be flexible. The easiest approach is a docker-compose.yml that:

  • Uses a dev command (hot reload)

  • Bind-mounts your working directory

  • Uses a named volume for dependency directories (so you don’t overwrite container-installed deps with an empty host folder)

# docker-compose.yml services: web: build: context: . target: deps working_dir: /app command: sh -c "npm install && npm run dev" ports: - "3000:3000" volumes: - .:/app - node_modules:/app/node_modules environment: - NODE_ENV=development db: image: postgres:16-alpine environment: POSTGRES_USER: app POSTGRES_PASSWORD: app POSTGRES_DB: app ports: - "5432:5432" volumes: - pgdata:/var/lib/postgresql/data volumes: node_modules: pgdata: 

This gives you a nice workflow:

docker compose up --build 

In dev, your edits instantly reflect inside the container via the bind mount.

5) Run as non-root (and set file permissions intentionally)

Many official images default to root. Running as root makes container breakouts more dangerous and can cause confusing permission bugs in mounted volumes.

At minimum:

  • Create a dedicated user (adduser/useradd)

  • Switch to it with USER

  • Ensure your app directory permissions allow runtime writes only where needed

If your app needs to write to a directory (uploads, cache), create it and chown it during build.

RUN mkdir -p /app/tmp && chown -R app:app /app/tmp 

6) Add a health endpoint + HEALTHCHECK

Containers can be “running” while your app is broken (deadlocks, failed DB connections, etc.). A health endpoint lets Docker and orchestrators detect failures and restart unhealthy containers.

Example minimal Node health route (Express):

// src/health.js export function healthRoute(req, res) { res.status(200).json({ ok: true }); } 
// src/server.js import express from "express"; import { healthRoute } from "./health.js"; const app = express(); app.get("/health", healthRoute); app.listen(3000, () => console.log("listening on 3000")); 

Then add a Docker HEALTHCHECK (as shown in the Dockerfile earlier). In Compose, you can also define it there.

7) Make containers configurable via environment variables

Don’t bake environment-specific settings into images. Use environment variables for configuration and keep defaults sensible.

Example: a database URL and port with defaults:

// config.js export const PORT = process.env.PORT ?? 3000; export const DATABASE_URL = process.env.DATABASE_URL ?? "postgres://app:app@db:5432/app"; 

In Compose:

environment: - PORT=3000 - DATABASE_URL=postgres://app:app@db:5432/app 

8) Keep secrets out of images

Never COPY .env into your image. Even if you delete it later, it may still exist in older layers. Keep secrets in:

  • Environment variables (dev)

  • Secret managers (prod) like your cloud provider’s secrets service

  • Docker/Compose secrets (when available)

And ensure .env is in .dockerignore.

9) Use BuildKit cache mounts for faster dependency installs (optional)

If you build often, caching package manager data helps. With BuildKit enabled, you can mount caches:

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

This doesn’t change the final image; it just speeds up rebuilds on the same machine/CI runner.

10) A “production run” checklist

  • Use multi-stage builds to keep runtime minimal.

  • Pin major versions (e.g., node:20-alpine) and rebuild regularly for security updates.

  • Run as non-root.

  • Add a healthcheck and a real /health endpoint.

  • Use .dockerignore to shrink context and avoid leaks.

  • Config via environment; no secrets in images.

  • Don’t install dev deps in production images.

Quick commands you’ll actually use

# Build a production image docker build -t myapp:prod . # Run it locally docker run --rm -p 3000:3000 --env PORT=3000 myapp:prod # Dev workflow with Compose docker compose up --build # Clean up unused images/containers (be careful) docker system prune 

Wrap-up: aim for “boring and repeatable”

The best Docker setup is the one that makes builds predictable, rebuilds fast, and production behavior consistent. If you adopt just three habits—.dockerignore, multi-stage builds, and a dev-vs-prod split—you’ll avoid most container pain and ship with confidence.


Leave a Reply

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