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.

Scale Your Backend - Need an Experienced Backend Developer?

We provide backend engineers who join your team as contractors to help build, improve, and scale your backend systems.

We focus on clean backend design, clear documentation, and systems that remain reliable as products grow. Our goal is to strengthen your team and deliver backend systems that are easy to operate and maintain.

We work from our own development environments and support teams across US, EU, and APAC timezones. Our workflow emphasizes documentation and asynchronous collaboration to keep development efficient and focused.

  • Production Backend Experience. Experience building and maintaining backend systems, APIs, and databases used in production.
  • Scalable Architecture. Design backend systems that stay reliable as your product and traffic grow.
  • Contractor Friendly. Flexible engagement for short projects, long-term support, or extra help during releases.
  • Focus on Backend Reliability. Improve API performance, database stability, and overall backend reliability.
  • Documentation-Driven Development. Development guided by clear documentation so teams stay aligned and work efficiently.
  • Domain-Driven Design. Design backend systems around real business processes and product needs.

Tell us about your project

Our offices

  • Copenhagen
    1 Carlsberg Gate
    1260, København, Denmark
  • Magelang
    12 Jalan Bligo
    56485, Magelang, Indonesia

More articles

How Oslo and Copenhagen Startups Cut Backend Costs Without Cutting Quality

You just ran payroll and noticed that your two backend engineers cost more than your entire sales team combined. In Oslo or Copenhagen, that's not unusual — it's just math that gets harder to justify every quarter.

Read more

Scanning Your Docker Image for Vulnerabilities Is Not Optional

Your Docker image inherits every vulnerability in its base image and every package you install. Without scanning, you don't know what you're shipping to production — and neither does your security team until an audit or incident reveals it.

Read more

Stop Running Every Check on Every Commit

Running the full pipeline on every commit is a default, not a best practice. Selective execution based on what actually changed is one of the most underused techniques for reducing CI cost and developer wait time.

Read more

How to Explain a Technical Problem to Someone Who Is Not Technical

The ability to communicate technical constraints, risks, and decisions to non-technical stakeholders is not a soft skill at the margins of engineering — it directly determines whether engineering work gets appropriate resources, time, and support.

Read more