What a Production-Ready Dockerfile Actually Looks Like
by Arif Ikhsanudin, Backend Developer
The Dockerfile that gets copy-pasted into production
Most tutorials show you a Dockerfile like this:
FROM node:20
WORKDIR /app
COPY . .
RUN npm install
EXPOSE 3000
CMD ["node", "src/index.js"]
This works for learning. In production, it runs as root, has no health check, doesn't handle process signals correctly, ships dev dependencies, bloats the image with build artifacts, and uses an unpinned tag that will silently change under you. Each of those is a real operational problem.
Here's what filling in those gaps actually looks like.
Pin your base image
FROM node:20 resolves to whatever node:20 points to today. Next month it might be a different patch version, or it might have been rebuilt with a different system package. For reproducibility, pin to a digest:
FROM node:20.12.2-alpine3.19
Or, for cryptographic reproducibility:
FROM node:20.12.2-alpine3.19@sha256:bf77dc26e48ea95fca9d1aceb5acfa69d2e546b765ec2abfb502975f1a2d4def
The @sha256: digest guarantees you're building against exactly the image you tested against, regardless of what the tag points to in the future. Your CI pipeline's image scanning results are only meaningful if the image being scanned is the one that ships.
The tradeoff: you have to update the pinned version manually. Tooling like Renovate or Dependabot handles this automatically for Dockerfiles.
Multi-stage: build vs runtime
A production Dockerfile for a Node.js service should separate the build environment from the runtime:
FROM node:20.12.2-alpine3.19 AS deps
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci
FROM node:20.12.2-alpine3.19 AS build
WORKDIR /app
COPY --from=deps /app/node_modules ./node_modules
COPY . .
RUN npm run build
FROM node:20.12.2-alpine3.19 AS runtime
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci --omit=dev
COPY --from=build /app/dist ./dist
Three stages: dependency installation (cached independently), build (TypeScript compilation, bundling), and runtime (production deps only, compiled output only). Build tools and devDependencies never enter the final image.
Run as a non-root user
By default, processes inside a Docker container run as root (UID 0). If your container is compromised, the attacker has root inside the container. Depending on your configuration (volume mounts, host networking, privileged mode), that can translate to significant host-level access.
Alpine-based images include a node user. Use it:
FROM node:20.12.2-alpine3.19 AS runtime
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci --omit=dev
COPY --from=build /app/dist ./dist
# Set ownership before switching user
RUN chown -R node:node /app
USER node
EXPOSE 3000
CMD ["node", "dist/index.js"]
The chown before USER node ensures the node user can read the application files. If WORKDIR /app creates the directory as root and you switch users before copying files, the copy will succeed but the node user may not have read access depending on the base image's umask.
For Java/Spring Boot with the eclipse-temurin image, there's no built-in non-root user — create one:
FROM eclipse-temurin:17-jre-alpine
RUN addgroup -S appgroup && adduser -S appuser -G appgroup
WORKDIR /app
COPY --from=build /app/target/app.jar app.jar
RUN chown appuser:appgroup app.jar
USER appuser
ENTRYPOINT ["java", "-jar", "app.jar"]
Handle process signals correctly
When Kubernetes or Docker sends SIGTERM to stop a container, the signal goes to PID 1. If your CMD is CMD ["node", "src/index.js"], node is PID 1 and receives the signal — good. But if your CMD uses a shell:
CMD node src/index.js # shell form — spawns sh as PID 1, node as child
The shell (sh) becomes PID 1. SIGTERM goes to sh, not node. Node never gets the shutdown signal and your graceful shutdown logic never runs. Use exec form (JSON array) to ensure your application is PID 1:
CMD ["node", "dist/index.js"] # exec form — node is PID 1
If you have shell logic that must run before your app (environment variable processing, config templating), use tini as an init process:
RUN apk add --no-cache tini
ENTRYPOINT ["/sbin/tini", "--"]
CMD ["node", "dist/index.js"]
tini handles signal forwarding and zombie process reaping. It's a ~20KB binary that solves real production behavior problems.
Add a HEALTHCHECK
Without a HEALTHCHECK, Docker (and orchestrators that use Docker's native health checking) considers your container healthy the moment it starts. If your app takes 30 seconds to warm up, or if it gets into a state where it's running but not serving, nothing tells the orchestrator to restart it.
HEALTHCHECK --interval=30s --timeout=5s --start-period=30s --retries=3 \
CMD wget -qO- http://localhost:3000/health || exit 1
--start-period=30s tells Docker not to count failed health checks during the startup window, which prevents your container from being marked unhealthy while it's still initializing.
For JVM applications, set the start period generously — 60s to 90s is not unusual for Spring Boot apps with significant startup time.
Note: Kubernetes doesn't use Docker's HEALTHCHECK — it uses readinessProbe and livenessProbe in the Pod spec. But the HEALTHCHECK is still useful for local Docker and Docker Compose environments.
Set resource expectations and environment defaults
ENV NODE_ENV=production
ENV PORT=3000
Setting NODE_ENV=production in the Dockerfile ensures it's set even if the orchestrator doesn't inject it. Some libraries (Express, Sequelize) behave differently in production mode — logging verbosity, caching, error handling. Don't rely on callers to set this.
The full Dockerfile together
FROM node:20.12.2-alpine3.19 AS deps
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci
FROM node:20.12.2-alpine3.19 AS build
WORKDIR /app
COPY --from=deps /app/node_modules ./node_modules
COPY . .
RUN npm run build
FROM node:20.12.2-alpine3.19
WORKDIR /app
ENV NODE_ENV=production \
PORT=3000
RUN apk add --no-cache tini \
&& addgroup -S appgroup && adduser -S appuser -G appgroup
COPY package.json package-lock.json ./
RUN npm ci --omit=dev
COPY --from=build /app/dist ./dist
RUN chown -R appuser:appgroup /app
USER appuser
EXPOSE 3000
HEALTHCHECK --interval=30s --timeout=5s --start-period=30s --retries=3 \
CMD wget -qO- http://localhost:${PORT}/health || exit 1
ENTRYPOINT ["/sbin/tini", "--"]
CMD ["node", "dist/index.js"]
The gaps this still doesn't fill
This Dockerfile handles the runtime concerns. It doesn't configure secrets injection (use environment variables at runtime, not build args), doesn't set memory limits (handle at the orchestrator or via -XX:MaxRAMPercentage for JVM), and doesn't address image scanning (add that to your CI pipeline, not the Dockerfile).
Review your production Dockerfiles against this list. If you're missing non-root user and exec-form CMD, those are the two with the most immediate operational impact.