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*.jsonfirst. -
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
/healthendpoint. -
Use
.dockerignoreto 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