Tutorial Dockerfiles stop when the container starts. Production begins there. It has to stop cleanly, restart on schedule, hold a credential and stay small enough to ship.
This guide takes one Node.js app from tutorial Dockerfile to production Dockerfile in six steps.
Prerequisites
A current Docker release
A Node.js app with a lockfile, a build script that writes to
dist/, and a server on port 3000A
/healthendpoint that returns 200 when the app is ready
Step 1: Pin the base image
Image tags are mutable. A tag can point to a different image next month. Pin the version and the digest:
FROM node:22-slim@sha256:<digest>
docker pull node:22-slim prints the digest. Updates become a deliberate act. Docker's build best practices suggest GitHub Dependabot, configured for Docker, to open the update pull requests.
Step 2: Ship only what runs
A multi-stage build keeps the compiler, dev dependencies and source out of the final image. Build in one stage, copy the output into a clean one:
FROM node:22-slim@sha256:<digest> AS build
WORKDIR /app
COPY package*.json ./
RUN npm ci
COPY . .
RUN npm run build
FROM node:22-slim@sha256:<digest>
WORKDIR /app
ENV NODE_ENV=production
COPY package*.json ./
RUN npm ci --omit=dev
COPY --from=build /app/dist ./dist
Add a .dockerignore next to the Dockerfile:
node_modules
.git
.env
*.md
COPY . . now skips local modules, git history and your .env file. Run docker images to compare sizes.
Step 3: Drop root
Containers run as root by default. The official Node image ships a node user with UID 1000, per the Node.js Docker best practices. Switch to it after the installs:
USER node
If the app writes to disk, copy files with COPY --chown=node:node.
Step 4: Let the process receive SIGTERM
docker stop sends SIGTERM. The app has to receive it. Use the exec form:
CMD ["node", "dist/server.js"]
The shell form runs the app as a child of /bin/sh -c, which doesn't forward signals. Docker then waits out a grace period and force-kills the process. npm start adds another process in the way.
Node as PID 1 can ignore signals. Run with docker run --init, or add Tini to the image.
Then handle the signal in the app:
process.on('SIGTERM', () => {
server.close(() => process.exit(0));
});
server.close stops accepting new connections and exits once the open ones finish.
Step 5: Add a health check
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))"
Exit code 0 reports healthy. Exit code 1 reports unhealthy. Slim images carry few tools, so the check runs on Node itself. The flags are set explicitly so the behavior is visible in the file. See the Dockerfile reference for the full instruction.
docker ps shows (healthy) in the status column once the first check passes. Orchestrators have their own health settings. Point them at the same endpoint.
Step 6: Keep secrets out of the image
ARG and ENV values persist in the final image. For a build-time secret, such as a private registry token, use a secret mount:
RUN --mount=type=secret,id=npmrc,target=/root/.npmrc npm ci
docker build --secret id=npmrc,src=$HOME/.npmrc -t myapp .
The file is mounted for that one instruction. Skip the mount and the --secret flag if the build needs no private registry.
Runtime secrets arrive at start through the platform's secret store or a secrets manager. Never COPY a .env file into the image.
The finished Dockerfile
# syntax=docker/dockerfile:1
FROM node:22-slim@sha256:<digest> AS build
WORKDIR /app
COPY package*.json ./
RUN --mount=type=secret,id=npmrc,target=/root/.npmrc npm ci
COPY . .
RUN npm run build
FROM node:22-slim@sha256:<digest>
WORKDIR /app
ENV NODE_ENV=production
COPY package*.json ./
RUN --mount=type=secret,id=npmrc,target=/root/.npmrc npm ci --omit=dev
COPY --from=build /app/dist ./dist
USER node
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"]
Build and run it:
docker build --secret id=npmrc,src=$HOME/.npmrc -t myapp .
docker run --init -p 3000:3000 myapp
What this costs
The Dockerfile is longer. Digest pins need an update process, and Dependabot is the cheapest one. Running as non-root breaks any code that writes to root-owned paths. A strict health check can flag healthy containers as unhealthy, and orchestrators that act on that status will restart them.
Each cost is a one-time fix. The failures it prevents show up at 3am.