Docker Best Practices for Web Devs: Faster Builds, Safer Images, Happier Deployments
Docker is one of those tools that “works” even when used sloppily—until your builds take 10 minutes, your images hit 2GB, or a production incident turns into a game of “which container did this come from?” This guide is hands-on and aimed at junior/mid developers who ship web apps. You’ll learn a practical set of Docker best practices you can apply today: multi-stage builds, layer caching, non-root containers, health checks, and sensible Compose setups.
1) Start with a good Dockerfile mental model
Each instruction in a Dockerfile creates a filesystem layer. Layers are cached, so ordering matters. The goal is to:
- Maximize cache hits (so builds are fast).
- Minimize what ends up in the final image (so deploys are lean and secure).
- Run as a non-root user (so compromise impact is smaller).
We’ll use a Node.js API example (the principles apply to any stack), then show the same ideas for Python and PHP.
2) Use a .dockerignore (it’s not optional)
If you don’t add .dockerignore, Docker sends your entire project directory to the daemon as build context—including node_modules, logs, and secrets you forgot about. This slows builds and can leak data into images.
# .dockerignore node_modules dist build .tmp .cache *.log .env .git .gitignore Dockerfile docker-compose*.yml
Tip: keep the build context as small as possible. If your repo has multiple apps, build from a subfolder (e.g. docker build -f services/api/Dockerfile services/api).
3) Multi-stage builds: ship only what you need
Multi-stage builds let you compile/build in one stage (with dev tools installed), then copy just the final artifacts into a minimal runtime stage.
Example: a Node.js Express API built with TypeScript.
# Dockerfile # syntax=docker/dockerfile:1.6 FROM node:20-alpine AS deps WORKDIR /app # Copy only dependency manifests first (enables caching) COPY package.json package-lock.json ./ # Use cache mount to speed up npm installs (BuildKit) RUN --mount=type=cache,target=/root/.npm \ npm ci FROM node:20-alpine AS build WORKDIR /app COPY --from=deps /app/node_modules ./node_modules COPY tsconfig.json ./ COPY src ./src 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 to run COPY package.json package-lock.json ./ COPY --from=deps /app/node_modules ./node_modules COPY --from=build /app/dist ./dist # Drop privileges USER app EXPOSE 3000 CMD ["node", "dist/server.js"]
Why this works well:
COPY package*.jsonbeforeCOPY srckeeps dependency install cached even when code changes.- Build tools and source files don’t end up in the final image.
- Runs as a non-root user.
4) Avoid “latest” and pin versions strategically
FROM node:20-alpine is better than FROM node:latest. “Latest” can change silently and break your builds. For tighter reproducibility, pin to a minor/patch tag or even a digest.
- Good:
node:20-alpine(stable major) - Better:
node:20.11-alpine(stable minor) - Strict: pin by digest (max reproducibility)
Also pin OS packages if you rely on specific behavior (especially in production images).
5) Use BuildKit cache mounts for dependencies
In the Dockerfile above, this line is a big deal:
RUN --mount=type=cache,target=/root/.npm npm ci
It avoids re-downloading packages on every build. You’ll need BuildKit enabled (it usually is by default on modern Docker). For Python, you can do the same with pip.
# Example snippet for Python pip caching RUN --mount=type=cache,target=/root/.cache/pip \ pip install -r requirements.txt
6) Run as non-root (and understand file permissions)
Running as root inside a container is a common footgun. If an attacker exploits a vulnerability, root inside the container can still do damage (and in misconfigured environments, it can be worse).
For Alpine-based images, the pattern is:
RUN addgroup -S app && adduser -S app -G app USER app
If your app writes to disk (uploads, temp files), make sure those directories exist and are writable by app:
RUN mkdir -p /app/tmp && chown -R app:app /app
7) Health checks: make containers observable
Docker can report container health, which is helpful for local dev and some orchestration setups.
Add a lightweight health endpoint in your app (e.g. GET /health returning 200), then:
# Dockerfile snippet HEALTHCHECK --interval=30s --timeout=3s --start-period=10s --retries=3 \ CMD wget -qO- http://127.0.0.1:3000/health || exit 1
For Debian-based images, you might use curl instead. Keep the check fast and stable.
8) Docker Compose: local dev without pain
Compose is perfect for “API + DB + cache” setups. Use named volumes for databases and bind mounts for code (in dev).
# docker-compose.yml services: api: build: context: . dockerfile: Dockerfile target: runtime ports: - "3000:3000" environment: - NODE_ENV=production - DATABASE_URL=postgres://app:app@db:5432/app depends_on: - db 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: pgdata:
For development, you might override with a docker-compose.override.yml (auto-loaded by Compose) to mount source code and run a dev command:
# docker-compose.override.yml services: api: environment: - NODE_ENV=development volumes: - .:/app command: ["npm", "run", "dev"]
This keeps production settings clean while giving you a great dev workflow.
9) Don’t bake secrets into images
Common mistake: copying .env into the image or setting secrets via ENV in the Dockerfile. That makes secrets part of the image layers, and anyone with access to the image can potentially extract them.
- Use environment variables at runtime (Compose, Kubernetes, your hosting platform).
- Use secret managers when available (e.g. platform secrets, Vault).
- Keep
.envin.dockerignore.
For local dev with Compose, it’s fine to use env_file (still don’t copy it into the image):
# docker-compose.yml snippet services: api: env_file: - .env
10) Small images: less surface area, faster deploys
Smaller images:
- Pull faster in CI and prod.
- Have fewer packages (smaller attack surface).
- Are easier to reason about.
Practical tips:
- Prefer slim/alpine base images when compatible.
- Use multi-stage builds to avoid shipping compilers/toolchains.
- Install OS packages only when needed, and clean up if on Debian/Ubuntu (
rm -rf /var/lib/apt/lists/*).
Example (Debian-based) cleanup:
RUN apt-get update \ && apt-get install -y --no-install-recommends curl \ && rm -rf /var/lib/apt/lists/*
11) Quick patterns for Python and PHP
Python (FastAPI/Flask/etc.) example with multi-stage and non-root:
# Dockerfile (Python) # syntax=docker/dockerfile:1.6 FROM python:3.12-slim AS deps WORKDIR /app COPY requirements.txt ./ RUN --mount=type=cache,target=/root/.cache/pip \ pip install --prefix=/install -r requirements.txt FROM python:3.12-slim AS runtime WORKDIR /app ENV PYTHONDONTWRITEBYTECODE=1 ENV PYTHONUNBUFFERED=1 RUN useradd -m appuser COPY --from=deps /install /usr/local COPY . . USER appuser EXPOSE 8000 CMD ["python", "-m", "uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"]
PHP (Laravel-ish) note: many teams use php-fpm plus an Nginx container. Multi-stage is still useful for building assets with Node and copying public/build into the final image.
# Example idea: build assets then copy into php-fpm image FROM node:20-alpine AS assets WORKDIR /app COPY package.json package-lock.json ./ RUN npm ci COPY resources ./resources COPY vite.config.* ./ RUN npm run build FROM php:8.3-fpm-alpine AS runtime WORKDIR /var/www/html COPY . . COPY --from=assets /app/public/build ./public/build # create non-root user and set permissions as needed
12) A simple checklist you can apply today
- Add a
.dockerignoreand keep build context small. - Use multi-stage builds to ship only runtime artifacts.
- Order
COPYsteps for caching: manifests first, source code last. - Enable BuildKit cache mounts for package managers.
- Run as non-root and fix permissions intentionally.
- Add a
HEALTHCHECKfor basic observability. - Keep secrets out of images; inject at runtime.
- Avoid
latest; pin base images.
If you adopt just the first three items, you’ll usually cut build times significantly and reduce image size. Add the non-root + secrets discipline, and you’ve already moved into “production-grade” Docker usage.
Leave a Reply