Guides

Building Docker & Containers with open-source tools

This guide outlines the implementation of a production-ready, multi-stage Dockerfile designed for web applications. It focuses on minimizing image size, optimizing layer caching for CI/CD performance, and implementing non-root security principles.

45 minutes6 steps
1

Configure the Build Stage with Specific Base Images

Start with a specific versioned image rather than 'latest' to ensure build reproducibility. Use the full-featured image for the build stage to provide necessary build tools (compilers, headers) that will be discarded in the final image.

Dockerfile
FROM node:20.11-bookworm AS build
WORKDIR /app

⚠ Common Pitfalls

  • Using 'latest' tags which can break builds when upstream images update unexpectedly.
  • Using Alpine for the build stage when dependencies require glibc (common in Python/Node native modules).
2

Implement Layer Caching for Dependencies

Copy only dependency manifests (package.json, lockfiles) before copying the entire source code. This ensures that the 'install' layer is only re-run when dependencies change, significantly speeding up CI/CD pipelines.

Dockerfile
COPY package*.json ./
RUN npm ci --only=production

⚠ Common Pitfalls

  • Copying source code before running install, which invalidates the cache on every small code change.
  • Using 'npm install' instead of 'npm ci', which can result in inconsistent dependency versions.
3

Execute Build and Assets Compilation

Copy the remaining source files and execute the build command. Since dependencies are already cached in the previous layer, this step only processes application logic.

Dockerfile
COPY . .
RUN npm run build
4

Create the Minimal Runtime Stage

Initialize a new stage using a 'slim' or 'alpine' image. Copy only the necessary production artifacts (compiled code and production node_modules) from the build stage. This reduces the attack surface and image size by hundreds of megabytes.

Dockerfile
FROM node:20.11-bookworm-slim
WORKDIR /app
ENV NODE_ENV=production
COPY --from=build /app/dist ./dist
COPY --from=build /app/node_modules ./node_modules
COPY --from=build /app/package.json ./package.json

⚠ Common Pitfalls

  • Forgetting to set NODE_ENV=production, which causes many frameworks to include dev-only overhead.
  • Copying the entire /app directory instead of specific build artifacts, re-introducing build-time bloat.
5

Enforce Non-Root User Execution

By default, Docker containers run as root. Explicitly switch to a low-privileged user provided by the base image (like 'node') and ensure permissions are correctly set for the application directory.

Dockerfile
RUN chown -R node:node /app
USER node
EXPOSE 3000
CMD ["node", "dist/main.js"]

⚠ Common Pitfalls

  • Setting USER before chown, which prevents the user from having permissions to the application files.
  • Binding to ports below 1024 (like 80) which requires root privileges; use high ports like 3000 instead.
6

Optimize with .dockerignore

Create a .dockerignore file to prevent local development artifacts, logs, and secrets from being sent to the Docker daemon during the build context transfer.

.dockerignore
node_modules
npm-debug.log
.git
.env
.vscode
dist

⚠ Common Pitfalls

  • Including .git folders, which significantly increases build context size and can leak source history.
  • Including local node_modules which may contain binaries incompatible with the container OS.

What you built

By following this multi-stage pattern, you achieve a secure, high-performance container image. The resulting image contains only runtime essentials, executes as a non-privileged user, and leverages Docker's layer caching for rapid deployment cycles.