Dockerfile for a Node.js Application
A production-ready Dockerfile for Node.js requires more thought than simply installing dependencies and copying files. The three most important practices — multi-stage builds for small images, layer ordering for cache efficiency, and non-root user execution for security — each require deliberate design. Getting these right means faster CI builds, smaller attack surfaces, and more reliable production containers. This example implements all three practices. The build stage installs production dependencies (npm ci --only=production installs exactly what's in package-lock.json and excludes devDependencies). The runtime stage starts fresh from node:20-alpine, adds a non-root user, copies only the node_modules and application code from the build stage, and sets the user before any CMD or EXPOSE instruction. Layer caching is one of Docker's most important performance features and the reason COPY package*.json ./ and RUN npm ci appear before COPY . . in this example. Docker builds each instruction as a cache layer and reuses cached layers when the files feeding into that layer haven't changed. By copying package.json and package-lock.json first and running npm ci before copying the rest of the application code, the npm install layer only re-runs when dependencies change. Your application code changes on every commit, but your dependencies change far less frequently — this ordering means most builds skip the time-consuming npm install step entirely. npm ci vs npm install: npm ci is specifically designed for automated environments. It installs exactly the versions in package-lock.json (deterministic, reproducible), fails if package-lock.json is out of sync with package.json (catches drift), and deletes the node_modules directory before installing (clean state). npm install is for interactive development where you want flexibility; npm ci is for builds and deployment. The HEALTHCHECK instruction tells Docker (and orchestrators like Kubernetes and ECS) how to determine if the container is healthy. The wget -qO- http://localhost:3000/health checks your application's health endpoint every 30 seconds with a 3-second timeout. Orchestrators use this health check to route traffic only to healthy containers and automatically restart unhealthy ones. Alpine base image: node:20-alpine provides a Node.js installation on Alpine Linux, a minimal distribution that produces images around 50-80 MB compared to 350+ MB for the full Debian-based node:20 image. The trade-off is that some npm packages with native extensions may not compile on Alpine's musl libc — test with alpine variants before committing to them for packages with native dependencies. Tips: pin the Node.js version exactly (node:20.12.0-alpine rather than node:20-alpine) so your builds are reproducible and you're not surprised when a major Node.js version automatically updates. Add the pinned version to your Renovate or Dependabot configuration to receive automated update PRs.
# Build stage FROM node:20-alpine AS builder WORKDIR /app COPY package*.json ./ RUN npm ci --only=production # Runtime stage FROM node:20-alpine WORKDIR /app RUN addgroup -S appgroup && adduser -S appuser -G appgroup COPY --from=builder /app/node_modules ./node_modules COPY . . USER appuser EXPOSE 3000 HEALTHCHECK --interval=30s --timeout=3s CMD wget -qO- http://localhost:3000/health || exit 1 CMD ["node", "server.js"]
FAQ
- Why use a multi-stage Docker build for Node.js?
- Multi-stage builds let you install build tools and devDependencies in an intermediate stage, then copy only the production artifacts to the final image. This results in a significantly smaller image with fewer attack surfaces.
- Should I use npm ci or npm install in Docker?
- Use npm ci in Docker. It installs exactly what is in package-lock.json for reproducible builds and fails if the lock file is out of sync, catching version drift before it reaches production.
- Why run the container as a non-root user?
- Running as root inside a container is a security risk. If an attacker escapes the container, they land as root on the host. A non-root user limits the blast radius of any container escape vulnerability.
Related Examples
Building a production Python Docker image requires understanding several Python-...
.gitignore for a Node.js ProjectA .gitignore file is one of the first files you should create in any new project...
Manage a .env Environment FileEnvironment files are the standard mechanism for injecting configuration and sec...