Dockerfile Best Practices for Web Apps: Faster Builds, Smaller Images, Safer Containers
If you’ve ever shipped a web app in Docker and thought “why is this image 1.5GB?” or “why does it rebuild everything every time?”, this guide is for you. We’ll focus on practical Dockerfile techniques you can apply immediately to Node/TypeScript (and most web stacks): caching dependencies correctly, multi-stage builds, running as a non-root user, minimizing attack surface, and making containers more observable.
All examples below are working and copy/paste friendly.
1) Start With the Right Base Image (and Pin It)
For most web apps, pick a small, maintained base image. In the Node ecosystem, -alpine is common, but note that Alpine uses musl (not glibc), which can occasionally cause native module issues. A good default is Debian slim, which is still small and tends to be more compatible.
- Good default:
node:20-bookworm-slim - Smaller but sometimes tricky:
node:20-alpine
Also: pin major versions at minimum. Even better, pin to a digest for fully reproducible builds.
# example (major pin) FROM node:20-bookworm-slim # example (digest pin; digest changes when you intentionally update) # FROM node:20-bookworm-slim@sha256:...
2) Use Layer Caching Correctly (Dependency Install Comes First)
A common mistake is copying your entire project into the image before installing dependencies. That invalidates Docker’s cache on every code change and forces a full reinstall.
Instead: copy only dependency manifests first, install, then copy the rest.
# syntax=docker/dockerfile:1 FROM node:20-bookworm-slim AS deps WORKDIR /app # Copy only manifests first (max cache reuse) COPY package.json package-lock.json ./ RUN npm ci --include=dev FROM node:20-bookworm-slim AS dev WORKDIR /app COPY --from=deps /app/node_modules ./node_modules COPY . . EXPOSE 3000 CMD ["npm", "run", "dev"]
npm ci is preferred in containers because it’s reproducible and fast (it uses the lockfile strictly). For pnpm or yarn, similar rules apply (copy lockfile first, install, then copy sources).
3) Multi-Stage Builds: Ship Only What You Need
Most web apps need build tools (typescript, bundlers, native compilers) only during build time. You can keep them out of the final runtime image using multi-stage builds.
Here’s a production-ready Dockerfile for a Node + TypeScript API (e.g., Express, Fastify, Nest). It compiles TypeScript in a builder stage and ships only the compiled output plus production dependencies.
# syntax=docker/dockerfile:1 FROM node:20-bookworm-slim AS build WORKDIR /app # Install deps (including dev deps) for build COPY package.json package-lock.json ./ RUN npm ci --include=dev # Copy source and build COPY tsconfig.json ./ COPY src ./src RUN npm run build # produces dist/ # ---- runtime image ---- FROM node:20-bookworm-slim AS runtime WORKDIR /app ENV NODE_ENV=production # Install only production deps in runtime image COPY package.json package-lock.json ./ RUN npm ci --omit=dev # Copy compiled code from build stage COPY --from=build /app/dist ./dist EXPOSE 3000 CMD ["node", "dist/server.js"]
This approach typically shrinks images dramatically and reduces the number of packages exposed in production.
4) Don’t Run as Root
By default, many images run as root. That’s convenient, but risky. If your app (or a dependency) is compromised, running as root can make escalation and container breakout attempts easier.
Create a non-root user and switch to it. Many official images already have a node user, but it’s good to be explicit and ensure file ownership is correct.
FROM node:20-bookworm-slim WORKDIR /app # Create a non-root user (if needed) RUN useradd -m -u 10001 appuser COPY package.json package-lock.json ./ RUN npm ci --omit=dev COPY . . # Make sure the app directory is owned by the non-root user RUN chown -R appuser:appuser /app USER appuser EXPOSE 3000 CMD ["node", "server.js"]
If you’re using a multi-stage build, apply USER in the final stage (runtime), and copy files with correct ownership using --chown when supported:
COPY --chown=appuser:appuser --from=build /app/dist ./dist
5) Add a .dockerignore (It Matters More Than You Think)
Your build context is everything Docker sends to the daemon. If you accidentally send node_modules, logs, test artifacts, or build output, builds get slower and caches get invalidated unexpectedly.
Create a .dockerignore like this:
node_modules npm-debug.log yarn-error.log .DS_Store .git .gitignore dist build coverage .env .env.*
Keeping your context lean speeds up builds locally and in CI.
6) Use BuildKit Cache Mounts for Faster Rebuilds (Optional but Great)
If your environment supports BuildKit (most modern Docker installs do), you can cache package manager data between builds. This can save minutes in CI.
# syntax=docker/dockerfile:1 FROM node:20-bookworm-slim AS deps WORKDIR /app COPY package.json package-lock.json ./ # Cache npm's download directory across builds RUN --mount=type=cache,target=/root/.npm \ npm ci --include=dev
This is especially useful when dependencies don’t change often but you rebuild frequently.
7) Make Your Container Observable: Healthchecks and Logging
In production, orchestration platforms (Kubernetes, ECS, Nomad, etc.) want signals about whether your container is healthy. A HEALTHCHECK gives a standardized way to verify your app is responding.
If your app exposes a health endpoint (recommended), you can use it:
FROM node:20-bookworm-slim WORKDIR /app # ... copy/install omitted for brevity ... EXPOSE 3000 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"]
Logging tip: write logs to stdout/stderr (don’t log to files inside the container unless you have a reason). Most platforms collect stdout/stderr automatically.
8) Keep Secrets Out of Images (Use Runtime Environment Variables)
Never bake secrets into images via COPY . . (if your repo contains .env) or via ARG that ends up in layers.
- Use
.dockerignoreto exclude.envfiles. - Pass secrets at runtime using environment variables or a secrets manager.
- In CI, prefer secret injection features (GitHub Actions secrets, GitLab variables, Vault, etc.).
If you need build-time private registry access, use BuildKit secrets (so they don’t persist in layers). Example for npm token:
# syntax=docker/dockerfile:1 FROM node:20-bookworm-slim AS deps WORKDIR /app COPY package.json package-lock.json ./ # Use a secret at build time (does not persist in final image layers) RUN --mount=type=secret,id=npmrc,target=/root/.npmrc \ npm ci --include=dev
Build with:
docker build --secret id=npmrc,src=$HOME/.npmrc -t myapp:dev .
9) A “Best-Practice” Production Dockerfile Template
Here’s a strong baseline you can adapt for most Node web apps. It includes: proper caching, multi-stage build, non-root user, and a healthcheck. It assumes you produce dist/ via npm run build.
# syntax=docker/dockerfile:1 FROM node:20-bookworm-slim AS build WORKDIR /app COPY package.json package-lock.json ./ RUN --mount=type=cache,target=/root/.npm npm ci --include=dev COPY . . RUN npm run build FROM node:20-bookworm-slim AS runtime WORKDIR /app ENV NODE_ENV=production RUN useradd -m -u 10001 appuser COPY package.json package-lock.json ./ RUN --mount=type=cache,target=/root/.npm npm ci --omit=dev COPY --from=build --chown=appuser:appuser /app/dist ./dist USER appuser EXPOSE 3000 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"]
10) Quick Checklist You Can Apply Today
- Copy
package.json/lockfilefirst, then install deps, then copy app code. - Use multi-stage builds to keep build tools out of production images.
- Add a
.dockerignoreto keep context small and builds fast. - Run as a non-root user in the final runtime stage.
- Install only production dependencies in the final image (
npm ci --omit=dev). - Prefer stdout/stderr logs; add a
HEALTHCHECKif your platform benefits from it. - Keep secrets out of images; inject them at runtime or via BuildKit secrets.
Once you adopt these patterns, you’ll usually see faster CI builds, smaller images, fewer production surprises, and a more secure baseline—without making your workflow harder.
Leave a Reply