Docker Best Practices for Web Developers: Smaller Images, Faster Builds, Safer Deploys
Docker can make local dev and deployments predictable—but only if you avoid the classic pitfalls: gigantic images, slow builds, containers running as root, and “works on my machine” config drift. This guide is a hands-on set of Docker best practices you can apply today, with working examples you can copy into your projects.
1) Start with a Good Dockerfile Pattern
A solid default for most web apps is:
- Use a small base image.
- Cache dependencies early (so rebuilds are fast).
- Use a non-root user.
- Use multi-stage builds when you compile assets or install build tools.
Here’s a practical Node.js example (works great for many SSR or API projects too). It builds dependencies once, then runs with a minimal runtime stage.
# syntax=docker/dockerfile:1 # ---- deps stage ---- FROM node:20-alpine AS deps WORKDIR /app # Copy only files needed to install deps (better cache) COPY package.json package-lock.json ./ RUN npm ci # ---- build stage (optional) ---- FROM node:20-alpine AS build WORKDIR /app COPY --from=deps /app/node_modules ./node_modules COPY . . # If you have a build step (React/Vite/Next, etc.) RUN npm run build # ---- runtime stage ---- 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 ./package.json COPY --from=build /app/node_modules ./node_modules # If you built assets, copy the build output only COPY --from=build /app/dist ./dist USER app EXPOSE 3000 # Change to your real start command: CMD ["node", "dist/server.js"]
Why this helps: The final image doesn’t include build tools, source code you don’t need, or caches. That means smaller images, faster pulls, and fewer security issues.
2) Use a .dockerignore (It’s Not Optional)
Without .dockerignore, Docker sends your entire project directory to the build context—even node_modules, logs, and test artifacts. That slows down builds and can leak secrets.
# .dockerignore node_modules dist .build .cache .git .gitignore *.log .env coverage Dockerfile docker-compose.yml
Tip: Keep Dockerfile out of the context only if you copy it from elsewhere; otherwise you can leave it in. The key is: don’t ship dependency folders, Git history, or secrets into builds.
3) Speed Up Builds with Layer Caching
Docker caches layers. If you copy your entire project before installing dependencies, every code change invalidates the dependency layer and forces a full reinstall.
Bad (slow rebuilds):
COPY . . RUN npm ci
Good (fast rebuilds):
COPY package.json package-lock.json ./ RUN npm ci COPY . .
This one change often takes rebuilds from minutes to seconds.
4) Don’t Run as root
Many base images default to root. If an attacker breaks out of your app process, root makes things worse. Create a dedicated user and run the app under it.
RUN addgroup -S app && adduser -S app -G app USER app
If your app needs to bind to privileged ports (<1024), use a reverse proxy (like Nginx) or bind to 3000 internally and map ports externally.
5) Use Environment Variables Correctly (and Safely)
Use ENV in the image for non-sensitive defaults, and pass real configuration at runtime. Never bake secrets into images or commit them in docker-compose.yml.
Runtime examples:
# Pass env vars at run time docker run -e NODE_ENV=production -e DATABASE_URL="postgres://..." myapp:latest
For Compose, prefer an .env file (not committed) and reference variables:
# docker-compose.yml services: web: image: myapp:latest environment: - NODE_ENV=production - DATABASE_URL=${DATABASE_URL}
Rule of thumb: secrets belong in a secret manager (or Compose secrets / Swarm / Kubernetes secrets), not in the Dockerfile.
6) Make Containers “12-Factor Friendly”
Containers should be disposable and stateless. Two practical habits:
- Write logs to stdout/stderr (don’t log to files).
- Store uploads/data in volumes or external storage (S3, DB), not inside the container filesystem.
Example: add a named volume for persistent data (e.g., database). This is what a local dev stack might look like.
# docker-compose.yml services: db: image: postgres:16-alpine environment: - POSTGRES_PASSWORD=postgres - POSTGRES_DB=app volumes: - dbdata:/var/lib/postgresql/data ports: - "5432:5432" web: build: . environment: - DATABASE_URL=postgres://postgres:postgres@db:5432/app depends_on: - db ports: - "3000:3000" volumes: dbdata:
7) Add a Healthcheck (It Saves You in Production)
Orchestrators (Compose, Swarm, Kubernetes) behave much better when they can tell if your container is healthy. Add a simple HTTP endpoint like /health and configure a health check.
# In Dockerfile (example) HEALTHCHECK --interval=30s --timeout=3s --start-period=10s --retries=3 \ CMD wget -qO- http://127.0.0.1:3000/health || exit 1
For apps that don’t ship wget, use curl or implement a tiny TCP check. The idea: fail fast when the app is stuck.
8) Pin Versions and Avoid “Latest” in Production
latest is convenient locally but dangerous in CI/CD because it changes over time. Pin base images and dependency installs.
- Use
node:20-alpinerather thannode:latest. - Prefer lockfiles (
package-lock.json,poetry.lock,composer.lock).
This makes builds reproducible—your future self will thank you.
9) Keep Images Small (and Measurably So)
Smaller images pull faster and have fewer packages to patch. Practical tactics:
- Use Alpine (when compatible) or slim variants.
- Multi-stage builds to keep build tools out of runtime images.
- Clean package caches (especially in Debian/Ubuntu images).
Example (Debian-based) cleanup pattern:
RUN apt-get update \ && apt-get install -y --no-install-recommends ca-certificates curl \ && rm -rf /var/lib/apt/lists/*
After building, check size:
docker images | head
If your “simple API” image is 1.5GB, that’s a red flag you can fix.
10) A Quick Checklist You Can Apply Today
- Dockerfile: multi-stage, dependency caching, non-root user.
- .dockerignore: exclude dependencies, logs, secrets, VCS.
- Config: runtime env vars, no baked-in secrets.
- Ops-ready: healthcheck, logs to stdout, stateless containers.
- Reproducible: pin versions, use lockfiles, avoid
latest. - Small: slim/alpine base, remove caches, copy only what you need.
Try It: Build and Run Locally
From your project root (with the Dockerfile and .dockerignore), run:
# Build docker build -t myapp:dev . # Run docker run --rm -p 3000:3000 myapp:dev
If you’re using Compose:
docker compose up --build
Once you adopt these patterns, you’ll notice a real difference: faster rebuilds, fewer “mystery” bugs across machines, and a smoother path from local dev to production.
Leave a Reply