Docker Best Practices for Web Apps: Faster Builds, Smaller Images, Safer Containers (Hands-On)
Docker can make local dev and deployments repeatable—but only if your images are fast to build, small to ship, and safe to run. This guide is a practical checklist you can apply today, with working examples you can copy into your project. We’ll focus on web apps (Node.js as the example), but the patterns apply to most stacks.
1) Start with the right base image (and pin it)
Choose a base image that matches your runtime needs. For Node.js, node:20-slim is a common balance of compatibility and size. Pin versions so a random upstream update doesn’t break your build.
-
Prefer
-slimor-alpinewhen you know your native dependencies will compile cleanly. -
Pin major (and ideally minor/patch) versions:
node:20.11-sliminstead ofnode:latest. -
Use multi-stage builds to keep build tools out of the final image.
2) Use a .dockerignore to avoid “build context bloat”
A slow Docker build is often caused by sending too much stuff into the build context. Add a .dockerignore early.
# .dockerignore node_modules dist coverage .git .gitignore Dockerfile docker-compose.yml npm-debug.log .env .vscode .idea
This reduces build time and prevents accidentally copying secrets (like .env) into an image.
3) Cache dependencies properly (the “copy package.json first” pattern)
The order of Dockerfile instructions matters. Docker caches layers. If you copy all your source code before installing dependencies, you’ll reinstall packages on every code change. Instead: copy only the dependency manifests first.
4) A production-grade multi-stage Dockerfile (Node.js example)
This Dockerfile builds your app in one stage and runs it in another, producing a smaller final image. It also runs as a non-root user and includes a basic healthcheck.
# Dockerfile # syntax=docker/dockerfile:1 ARG NODE_VERSION=20.11.1 ########## 1) deps stage: install dependencies with good caching ########## FROM node:${NODE_VERSION}-slim AS deps WORKDIR /app # Copy only manifests first for caching COPY package.json package-lock.json ./ # Install prod + dev deps (needed to build) RUN npm ci ########## 2) build stage: compile / bundle / transpile ########## FROM node:${NODE_VERSION}-slim AS build WORKDIR /app COPY --from=deps /app/node_modules ./node_modules COPY . . # Example: build outputs into /app/dist RUN npm run build ########## 3) runtime stage: minimal image, prod deps only ########## FROM node:${NODE_VERSION}-slim AS runtime WORKDIR /app ENV NODE_ENV=production # Create a non-root user RUN useradd --create-home --shell /usr/sbin/nologin appuser # Copy manifests and install ONLY production deps COPY package.json package-lock.json ./ RUN npm ci --omit=dev && npm cache clean --force # Copy compiled output only (not the whole source) COPY --from=build /app/dist ./dist # If you serve HTTP on 3000 EXPOSE 3000 # Optional: healthcheck (adjust endpoint) HEALTHCHECK --interval=30s --timeout=3s --start-period=10s --retries=3 \ CMD node -e "fetch('http://127.0.0.1:3000/health').then(r=>process.exit(r.ok?0:1)).catch(()=>process.exit(1))" USER appuser CMD ["node", "dist/server.js"]
Why this works well:
-
npm ciis deterministic and faster for CI/builds thannpm install. -
Only
distis copied into runtime, keeping the final image small. -
Runs as
appuserinstead of root, reducing blast radius.
5) Local development with docker-compose (hot reload + volumes)
Production images optimize for minimal size and safety. Development wants fast iteration. Use docker-compose with bind mounts for live code updates.
# docker-compose.yml version: "3.9" services: web: image: myapp-dev build: context: . dockerfile: Dockerfile.dev ports: - "3000:3000" environment: - NODE_ENV=development volumes: - .:/app - /app/node_modules
And a development Dockerfile:
# Dockerfile.dev FROM node:20-slim WORKDIR /app COPY package.json package-lock.json ./ RUN npm ci COPY . . EXPOSE 3000 CMD ["npm", "run", "dev"]
- /app/node_modules is a “volume trick” to prevent your host’s node_modules from overwriting the container’s.
6) Pass config safely: environment variables, not baked-in secrets
Don’t bake secrets into images (API keys, DB passwords). Use environment variables at runtime.
-
For dev: use
docker-compose.ymlenvironmentor an.envfile that you do not copy into the image. -
For prod: inject env vars via your orchestrator (Kubernetes, ECS, etc.).
Example: reading a port safely in Node:
// server.js const http = require("http"); const port = Number(process.env.PORT || 3000); const server = http.createServer((req, res) => { if (req.url === "/health") { res.writeHead(200); return res.end("ok"); } res.writeHead(200, { "Content-Type": "text/plain" }); res.end("Hello from Docker\n"); }); server.listen(port, "0.0.0.0", () => { console.log(`Listening on ${port}`); });
7) Make images smaller (without breaking everything)
Smaller images pull faster and have fewer packages to patch. Practical wins:
-
Multi-stage builds (already shown) keep build tools out of runtime.
-
Copy only what you need (e.g.,
distnot the full repo). -
Clean package manager caches where appropriate (e.g.,
npm cache clean --force). -
Avoid installing “helpful” tools (curl, vim) in production images unless you truly need them.
If you’re building a front-end (React/Vite/etc.) and serving static files, consider using a minimal web server image for runtime (e.g., Nginx) while still building assets in a Node build stage.
8) Avoid running as root (and set file permissions deliberately)
By default, many images run as root. If an attacker escapes your app, root makes lateral movement easier. Create a user and switch to it (as in the Dockerfile above).
If your app needs to write to disk (uploads, temp files), create and chown those directories explicitly:
RUN mkdir -p /app/data && chown -R appuser:appuser /app/data
9) Add a health endpoint and use Docker healthchecks
A “container is running” isn’t the same as “app is healthy.” Add a simple /health endpoint and wire it into Docker’s HEALTHCHECK. Many platforms use this signal to restart unhealthy containers.
If your runtime image doesn’t have fetch available (older Node), use a minimal healthcheck script or install a tiny tool—but be mindful of image bloat.
10) Build and run commands you can copy-paste
Production-style build:
docker build -t myapp:prod . docker run --rm -p 3000:3000 -e PORT=3000 myapp:prod
Development with Compose:
docker compose up --build
11) Quick “pre-flight” checklist before shipping
-
.dockerignorepresent and excludes secrets and heavy folders. -
Multi-stage build used; runtime contains only what it needs.
-
Dependencies cached (copy manifests before source).
-
Runs as non-root user.
-
Health endpoint +
HEALTHCHECKincluded (when applicable). -
Secrets injected via environment, not copied into image layers.
-
Image size is reasonable (compare before/after with
docker images).
Wrap-up
Docker “best practices” aren’t abstract rules—they’re a set of small decisions that compound into faster builds, fewer surprises, and safer production containers. Start with the two biggest wins: .dockerignore + multi-stage builds. Then add non-root users and healthchecks. Once these are muscle memory, your Docker setup will stop being a source of flaky deployments and become a reliable part of your workflow.
Leave a Reply