Docker Best Practices for Web Developers: Smaller Images, Faster Builds, Safer Containers
Docker is one of those tools that feels magical—until your image is 1.8GB, builds take forever, and production containers run as root “because it works.” This guide focuses on practical Docker best practices you can apply today to ship web apps that build fast, run safely, and are easy to debug.
We’ll use a simple Node.js web app example, but the patterns apply to Python, PHP, Java, and more.
1) Start with a tight .dockerignore (it matters more than you think)
Docker sends your build context (the folder) to the Docker daemon. If that context is huge, builds are slow and caching becomes unreliable. Add a .dockerignore to keep junk out.
# .dockerignore node_modules npm-debug.log Dockerfile docker-compose.yml .git .gitignore .env dist coverage *.md
Rule of thumb: ignore anything that isn’t needed to build your app.
2) Use multi-stage builds to shrink production images
Multi-stage builds let you compile/install dependencies in one stage and copy only what you need into a lean runtime stage.
Example: Node.js app (build + run).
# Dockerfile # ---- Base dependencies stage ---- FROM node:20-alpine AS deps WORKDIR /app # Copy only dependency manifests first for better caching COPY package.json package-lock.json ./ RUN npm ci # ---- Build stage ---- FROM node:20-alpine AS build WORKDIR /app COPY --from=deps /app/node_modules ./node_modules COPY . . RUN npm run build # ---- Production runtime stage ---- FROM node:20-alpine AS runtime WORKDIR /app ENV NODE_ENV=production # Create a non-root user and group RUN addgroup -S app && adduser -S app -G app # Copy only what the app needs to run COPY --from=deps /app/node_modules ./node_modules COPY --from=build /app/dist ./dist COPY package.json ./ # Drop privileges USER app EXPOSE 3000 CMD ["node", "dist/server.js"]
Why this helps: build tools (and source files) don’t end up in production. Your final image is smaller and has fewer attack surfaces.
3) Make layer caching work for you (copy manifests first)
Docker caches layers. If you copy your whole project before installing dependencies, any code change invalidates the dependency layer and forces a full reinstall.
- Copy
package.json/package-lock.json(orrequirements.txt) first - Install dependencies
- Then copy the rest of the source
You saw that pattern above: it’s one of the highest ROI improvements for build times.
4) Don’t run as root (and don’t write where you shouldn’t)
Containers are not VMs; they share the host kernel. Running as root increases risk if an attacker escapes your app process.
- Create a dedicated user (like
app) - Switch with
USER app - Ensure your app writes only to intended directories (e.g.,
/tmp)
In the Dockerfile above, we created app and switched to it. That single change is a big win for production hardening.
5) Use environment variables correctly (and avoid baking secrets into images)
Never copy secrets (API keys, DB passwords) into your image. Images often get pushed to registries and cached on CI runners.
Use environment variables at runtime. For local development, docker compose is perfect.
# docker-compose.yml services: web: build: context: . dockerfile: Dockerfile ports: - "3000:3000" environment: - NODE_ENV=development - DATABASE_URL=postgres://postgres:postgres@db:5432/app depends_on: - db db: image: postgres:16-alpine environment: - POSTGRES_PASSWORD=postgres - POSTGRES_DB=app volumes: - pgdata:/var/lib/postgresql/data volumes: pgdata:
For real secrets in production, prefer platform secret managers (Kubernetes secrets, ECS task secrets, GitHub Actions secrets, Vault, etc.).
6) Add a HEALTHCHECK so orchestration can restart broken containers
When your app hangs or loses DB connectivity, “container is running” doesn’t mean “service is healthy.” Add a health endpoint and a HEALTHCHECK.
Example health route (Express):
// server.js (or equivalent) import express from "express"; const app = express(); app.get("/health", (req, res) => { // optionally: check DB connection, cache ping, etc. res.status(200).json({ ok: true }); }); app.listen(3000);
Then in your Dockerfile runtime stage:
HEALTHCHECK --interval=30s --timeout=3s --start-period=10s --retries=3 \ CMD wget -qO- http://127.0.0.1:3000/health || exit 1
This helps Docker (and orchestration platforms) detect unhealthy containers and replace them automatically.
7) Make containers easy to stop (handle SIGTERM)
In production, containers are often stopped with SIGTERM (e.g., during deploys). If your app ignores it, you can get dropped requests or corrupted state.
Node example:
const server = app.listen(3000); process.on("SIGTERM", () => { console.log("SIGTERM received, shutting down..."); server.close(() => { console.log("HTTP server closed."); process.exit(0); }); });
For Python (Gunicorn/Uvicorn), many servers already handle this well, but it’s worth verifying your stack’s graceful shutdown behavior.
8) Keep your images minimal (but don’t get obsessed)
Smaller images generally mean:
- Faster pulls and deployments
- Less attack surface
- Lower storage costs
Practical tips:
- Prefer slim/alpine images when they work (
node:alpine,python:slim) - Remove build-only dependencies using multi-stage builds
- Don’t install “nice-to-have” packages in production images
But don’t sacrifice debugging entirely. It’s okay to have a separate “debug” image (or use ephemeral debug containers) instead of bloating production.
9) Use BuildKit features for faster, cleaner builds (cache mounts)
If your environment supports BuildKit, you can speed up installs by caching package manager directories between builds.
Example for npm:
# 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 can make CI builds noticeably faster, especially when dependencies are large.
10) Quick checklist you can apply to most web apps
- Use
.dockerignoreto shrink build context - Multi-stage builds to keep production images small
- Copy manifests first to maximize caching
- Run as non-root (create a user, use
USER) - Don’t bake secrets into images; use runtime env/secrets
- Add health checks for reliability
- Graceful shutdown for safe deploys
- Keep images minimal without killing debuggability
Putting it together: a simple local workflow
Build and run locally with Compose:
docker compose up --build
Rebuild after changes:
docker compose up --build --force-recreate
Inspect the final image size:
docker images | head
When you apply the patterns above, you should see smaller images, faster rebuilds, and fewer production surprises.
If you want, tell me your stack (Node/Python/PHP + framework) and whether you deploy to Kubernetes, ECS, or a VPS, and I’ll adapt the Dockerfile and Compose setup to match how you ship your app.
Leave a Reply