Docker for Web Developers: Images That Ship
A code-first walkthrough of writing Dockerfiles for web apps that actually ship: multi-stage builds, non-root users, .dockerignore, secrets, BuildKit caching, and docker compose for local dev.
Published on • August 12, 2026
AI Assistant

“Works on my machine” dies the day your app becomes a Docker image: the image you build on a laptop is the one that runs in CI and production. But there is a gap between an image that runs and an image that ships. A naive Dockerfile yields a bloated, root-running image that rebuilds from scratch on every commit; a good one is small, fast, and unprivileged. This code-first guide covers the practices that make web images ship - multi-stage builds, minimal base images, non-root users, .dockerignore, healthchecks, BuildKit caching, and secrets - ending in a full Node.js/Express Dockerfile plus Compose file.
Prerequisites
- Docker Engine 23.0+ (BuildKit is the default builder) or Docker Desktop
docker composeanddocker buildx- A Node.js app to containerize (Express, or Next.js with
output: "standalone")
docker --version && docker compose version && docker buildx version
Why Docker for web developers
- Consistent environments - identical behavior on laptop, CI, and server
- Dependency isolation - databases and services run alongside the app without polluting the host
- Reproducible deployment - you ship one artifact, the image, not a checklist of install steps
Locally, Compose spins up the whole stack - app, Postgres, Redis - with docker compose up.
Dockerfiles that ship
Multi-stage builds
Multiple FROM instructions create stages; you COPY only what the final stage needs and discard the build toolchain. The compiler, bundler, and dev dependencies stay in build stages; the final image holds only production node_modules and compiled output. See the complete example in Putting It All Together.
Minimal base image and non-root user
Pick a base matching the runtime: node:22-alpine for a Node service, nginx:alpine for static output, scratch for compiled Go. Smaller bases pull faster and shrink the attack surface. Avoid latest; pin a version tag or digest. Most images default to root; if your app is compromised, the attacker is root inside the container. Drop privileges with RUN ... adduser plus a USER instruction, set ownership with COPY --chown=, and write temp data to /tmp.
.dockerignore
A .dockerignore (gitignore-style patterns) keeps node_modules, .git, logs, and .env out of the build context and the image - smaller builds, no leaked secrets:
node_modules
dist
.next
.git
.env
.env.*
*.log
Healthchecks
A HEALTHCHECK tells orchestration when the container is truly ready, not just started. Compose’s depends_on: condition: service_healthy then waits for it:
HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \
CMD wget -qO- http://127.0.0.1:3000/health || exit 1
.env and secrets
Local config goes in .env (excluded by .dockerignore). Never bake real secrets into image layers - they persist in the registry forever. Use BuildKit secret mounts, which exist only for that RUN step:
RUN --mount=type=secret,id=db-password \
export DB_PASSWORD=$(cat /run/secrets/db-password) && npm run migrations:run
Docker Compose for local dev
Compose wires the app to its dependencies and enables hot-reload via Compose Watch. target: dev builds a watch-script stage; target: runner yields the slim production image. Run docker compose watch for auto-sync and docker compose run --rm server npm test for containerized tests.
services:
server:
build: { context: ., target: dev }
ports: [3000:3000]
environment: [DB_HOST=db]
develop:
watch:
- action: sync
path: ./src
target: /app/src
db:
image: postgres:16-alpine
environment: [POSTGRES_USER=app, POSTGRES_PASSWORD=app, POSTGRES_DB=app]
volumes: [pgdata:/var/lib/postgresql/data]
volumes:
pgdata:
BuildKit caching
BuildKit, the default builder since Docker Engine 23.0, skips unused stages, parallelizes independent ones, and caches precisely.
- Order layers - copy lockfiles first, install deps, then copy source, so deps only reinstall when the lockfile changes.
- Cache mounts - persist package caches across builds:
RUN --mount=type=cache,target=/root/.npm \
npm ci --omit=dev
- External cache in CI - push/pull cache as an image so ephemeral CI builders start warm:
docker buildx build \
--cache-from type=registry,ref=user/app:buildcache \
--cache-to type=registry,mode=max,ref=user/app:buildcache \
-t user/app:latest .
Image size optimization
- Multi-stage builds - ship only compiled output and runtime deps (often 10x smaller)
- Production-only deps -
npm ci --omit=devkeeps tooling out of the final image - Cache hygiene -
npm cache clean --forcein the sameRUNavoids baking a throwaway cache into a layer
Putting It All Together
A complete production setup for a Node/Express app - multi-stage, non-root, healthchecked:
FROM node:22-alpine AS deps
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci --omit=dev
FROM node:22-alpine AS builder
WORKDIR /app
COPY --from=deps /app/node_modules ./node_modules
COPY . .
RUN npm run build
FROM node:22-alpine AS runner
RUN addgroup -g 1001 nodejs && adduser -S nodejs -u 1001
WORKDIR /app
ENV NODE_ENV=production
COPY --from=deps /app/node_modules ./node_modules
COPY --from=builder /app/dist ./dist
USER nodejs
EXPOSE 3000
HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \
CMD wget -qO- http://127.0.0.1:3000/health || exit 1
CMD ["node", "dist/server.js"]
services:
server:
build: { context: ., target: runner }
image: myapp:latest
ports: [3000:3000]
environment: [NODE_ENV=production, DB_HOST=db]
depends_on:
db:
condition: service_healthy
restart: unless-stopped
db:
image: postgres:16-alpine
environment:
- POSTGRES_USER=app
- POSTGRES_DB=app
- POSTGRES_PASSWORD_FILE=/run/secrets/db-password
volumes: [pgdata:/var/lib/postgresql/data]
secrets:
db-password:
file: db/password.txt
volumes:
pgdata:
docker compose up --build -d
docker compose ps
curl http://localhost:3000/health
Conclusion & Next Steps
A Dockerfile that ships is small, secure, and cache-friendly. Next: pin the base image digest, wire --cache-to/--cache-from into CI, scan with docker scout, and explore Docker Debug and Compose Watch.
References / Sources
- Docker Docs - Multi-stage builds: https://docs.docker.com/build/building/multi-stage/
- Docker Docs - Building best practices: https://docs.docker.com/build/building/best-practices/
- Docker Docs - BuildKit: https://docs.docker.com/build/buildkit/
- Docker Docs - Optimize cache usage in builds: https://docs.docker.com/build/cache/optimize/
- Docker Docs - Containerize a Node.js application: https://docs.docker.com/guides/nodejs/containerize/
- Docker Blog - Docker for Web Developers: Getting Started with the Basics: https://www.docker.com/blog/docker-for-web-developers/