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

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

Docker can make “works on my machine” disappear—until builds get slow, images get huge, and production containers run as root. This guide focuses on practical Docker best practices you can apply today: multi-stage builds, caching, non-root runtime, health checks, secrets, and repeatable local dev. Examples use a Node.js web app, but the patterns apply to Python, PHP, and more.

1) Start with a .dockerignore (fastest win)

Your Docker build context is everything in the folder you run docker build from. Sending node_modules, logs, or build artifacts to the Docker daemon makes builds slower and cache less effective.

# .dockerignore node_modules npm-debug.log yarn-error.log dist build .cache .DS_Store .git .gitignore .env *.local 
  • Ignore .env so secrets don’t accidentally end up in images.

  • Ignore dist/build so you don’t bake old artifacts into new images.

2) Use multi-stage builds to ship only what you need

Multi-stage builds let you compile dependencies in a “builder” image, then copy only the final output into a slim runtime image. Result: smaller images and fewer vulnerabilities.

Example: Node.js + TypeScript app that outputs to dist/.

# Dockerfile # syntax=docker/dockerfile:1 FROM node:20-bookworm-slim AS deps WORKDIR /app # Copy only package files first to maximize cache hits COPY package.json package-lock.json ./ RUN npm ci FROM node:20-bookworm-slim AS build WORKDIR /app COPY --from=deps /app/node_modules ./node_modules COPY . . RUN npm run build FROM node:20-bookworm-slim AS runner WORKDIR /app ENV NODE_ENV=production # Copy only what runtime needs COPY --from=deps /app/node_modules ./node_modules COPY --from=build /app/dist ./dist COPY package.json ./ EXPOSE 3000 CMD ["node", "dist/server.js"] 
  • npm ci is deterministic and faster in CI than npm install.

  • Copying package*.json first allows Docker to cache dependency installation when app code changes.

  • The final image doesn’t include your source tree, tests, or build toolchain.

3) Make builds much faster with BuildKit cache mounts

If your Docker supports BuildKit (most modern setups do), you can cache package downloads between builds. This is especially helpful in CI.

# Dockerfile (excerpt) FROM node:20-bookworm-slim AS deps WORKDIR /app COPY package.json package-lock.json ./ # Cache npm download folder across builds RUN --mount=type=cache,target=/root/.npm \ npm ci 

To ensure BuildKit is enabled:

# One-off DOCKER_BUILDKIT=1 docker build -t myapp:dev .
Or configure Docker daemon / Docker Desktop settings

4) Run as a non-root user in production

Many base images default to root. If an attacker finds a vulnerability in your app, root inside a container can make exploitation worse. Run as a non-root user wherever possible.

# Dockerfile (runner stage excerpt) FROM node:20-bookworm-slim AS runner WORKDIR /app ENV NODE_ENV=production # Create app user RUN useradd -m -u 10001 appuser COPY --from=deps /app/node_modules ./node_modules COPY --from=build /app/dist ./dist COPY package.json ./ # Fix ownership (do this before switching users) RUN chown -R appuser:appuser /app USER appuser EXPOSE 3000 CMD ["node", "dist/server.js"] 
  • If your app writes files at runtime (uploads, caches), ensure directories are writable by the non-root user.

5) Add a health check so orchestrators can act

Kubernetes, Docker Compose, and many PaaS tools can use health checks to restart unhealthy containers and keep traffic away from broken instances.

First, add a lightweight endpoint in your app:

// dist/server.js (or your source equivalent) import express from "express"; const app = express(); app.get("/healthz", (_req, res) => res.status(200).send("ok")); app.listen(3000); 

Then define a Docker health check:

# Dockerfile (runner stage excerpt) HEALTHCHECK --interval=30s --timeout=3s --start-period=10s --retries=3 \ CMD node -e "fetch('http://127.0.0.1:3000/healthz').then(r=>process.exit(r.ok?0:1)).catch(()=>process.exit(1))" 

This makes failures visible and recoverable in production environments.

6) Don’t bake secrets into images—use env vars or secret mounts

A common mistake is copying .env into the image or using ARG for secrets in a way that ends up in the image history. Images get pushed to registries and shared; treat them as non-secret artifacts.

  • Use runtime environment variables: DATABASE_URL, JWT_SECRET, etc.

  • In Docker Compose, store secrets outside the image and inject them at runtime.

  • In CI, use secret stores (GitHub Secrets, GitLab CI variables, etc.).

Example: docker-compose.yml injecting environment variables:

services: web: build: . ports: - "3000:3000" environment: - DATABASE_URL=${DATABASE_URL} - JWT_SECRET=${JWT_SECRET} 

Put secrets in your local shell or a local-only file that is ignored by Git, but never COPY them into the image.

7) Prefer explicit, pinned base images

FROM node:latest is convenient but unstable—your build can change under you. Pin major versions (or even digests if you need strict reproducibility).

  • Good: FROM node:20-bookworm-slim

  • Risky: FROM node:latest

This reduces “sudden breakages” when upstream changes.

8) Keep layers cache-friendly (copy smart)

Docker caches each layer. If a layer changes, all subsequent layers rebuild. Organize your Dockerfile so frequently changing files come later.

  • Copy dependency manifests first (package.json, package-lock.json).

  • Install deps.

  • Copy application source last.

This pattern is already baked into the multi-stage example above. The “feel” you want: editing one file shouldn’t re-install dependencies.

9) Use Compose for local dev without “Dockerizing your workflow” too much

For junior/mid developers, the goal is: one command to run the stack (app + database) locally, with fast reloads. Use a dev-focused Compose file with bind mounts, but don’t ship it to production as-is.

# docker-compose.dev.yml services: web: image: myapp-dev build: context: . target: deps command: npm run dev working_dir: /app volumes: - .:/app - /app/node_modules ports: - "3000:3000" environment: - DATABASE_URL=postgres://postgres:postgres@db:5432/app db: image: postgres:16 environment: POSTGRES_PASSWORD: postgres POSTGRES_DB: app ports: - "5432:5432" 

Run it:

docker compose -f docker-compose.dev.yml up --build 
  • The anonymous volume /app/node_modules prevents your host node_modules from clobbering container-installed modules.

  • Use separate targets: a dev image that has tooling, and a prod image that is slim.

10) Basic hardening: read-only filesystem and minimal permissions

In production, consider restricting the container further:

  • Run as non-root (we did).

  • Use a read-only filesystem and mount a writable temp dir if needed.

  • Drop Linux capabilities if your platform supports it (common in Kubernetes).

Example Compose snippet:

services: web: image: myapp:prod read_only: true tmpfs: - /tmp 

If your app needs to write uploads, mount only that directory as writable instead of making the whole filesystem writable.

11) A quick “good default” production Dockerfile you can copy

Here’s a complete version combining the most useful practices (multi-stage, cache-friendly, non-root, healthcheck):

# Dockerfile # syntax=docker/dockerfile:1 FROM node:20-bookworm-slim AS deps WORKDIR /app COPY package.json package-lock.json ./ RUN --mount=type=cache,target=/root/.npm npm ci FROM node:20-bookworm-slim AS build WORKDIR /app COPY --from=deps /app/node_modules ./node_modules COPY . . RUN npm run build FROM node:20-bookworm-slim AS runner WORKDIR /app ENV NODE_ENV=production RUN useradd -m -u 10001 appuser COPY --from=deps /app/node_modules ./node_modules COPY --from=build /app/dist ./dist COPY package.json ./ RUN chown -R appuser:appuser /app USER appuser EXPOSE 3000 HEALTHCHECK --interval=30s --timeout=3s --start-period=10s --retries=3 \ CMD node -e "fetch('http://127.0.0.1:3000/healthz').then(r=>process.exit(r.ok?0:1)).catch(()=>process.exit(1))" CMD ["node", "dist/server.js"] 

12) Practical checklist for your next PR

  • Add .dockerignore to shrink build context.

  • Use multi-stage builds; ship only runtime artifacts.

  • Order layers for caching (copy manifests, install deps, then copy source).

  • Run as non-root and set permissions explicitly.

  • Don’t bake secrets into images; inject at runtime.

  • Pin base image versions (avoid latest).

  • Add a health check endpoint + Docker HEALTHCHECK.

  • Use Compose for local dev, but keep prod images lean.

If you apply just the first four items, you’ll usually see immediate improvements: smaller images, faster builds, and fewer production surprises.


Leave a Reply

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