Dockerising Node.js and Angular SSR for Production: Multi-Stage Builds, Non-Root, Healthchecks and nginx

Production Dockerfiles for a Node.js API and an Angular SSR app: multi-stage builds, node:slim versus node:alpine, a non-root user, signal handling, a healthcheck that works without curl, nginx in front with allowedHosts and proxy headers, build secrets, .dockerignore and image scanning.

Cover for the article Dockerising Node.js and Angular SSR for Production: Multi-Stage Builds, Non-Root, Healthchecks and nginx

A production Node.js image needs five things: a multi-stage build so only the runtime files ship, a pinned Node version, the built-in node user instead of root, a start command that runs node directly, and a healthcheck. For Angular SSR add two more: nginx in front, and allowedHosts plus proxy-header trust configured so the SSR server accepts only the hostnames you expect. The Dockerfiles below follow Docker's build guidance and the official Node image documentation. The Node API example is a trimmed version of the pattern used for the API behind this site, which runs as the node user with tini as PID 1 and a Node-based healthcheck.

Choose the base image: slim, alpine or default

The Node image documentation describes the variants. The default node:<version> image is the general-purpose one, built on Debian with many common packages. node:slim contains only the minimal packages Node needs, and the docs recommend the default unless you have space constraints. node:alpine uses musl libc instead of glibc and is about 25% smaller than slim, but the documentation warns that applications written for Debian will generally not run under Alpine, and that it omits tools such as git and bash. Debian variants are also published per release, such as bookworm (Debian 12) and trixie (Debian 13).

ImagelibcChoose it when
node:26-bookworm-slim (or -trixie-slim)glibcDefault for apps with native modules such as image processing or password hashing
node:26-alpinemuslPure JavaScript, or native packages that publish musl prebuilt binaries, and you want the smallest image
Docker Hardened ImagesvariesYou want a minimal, non-root-by-default base. Docker's Community tier is free under Apache 2.0; check the catalogue for a Node image

My API image uses Alpine because its native dependency, sharp, installs a prebuilt musl binary. If a build fails to compile a native module on Alpine, switch to slim instead of installing a compiler toolchain. On versions: Node 26 became the Active LTS line on 28 October 2026, with 26.13.0 as the current release, and Node 24 is in Maintenance. Angular 22 supports Node ^22.22.3 || ^24.15.0 || ^26.0.0. I explain how to choose in which Node.js version to run in production. Pin the major in an ARG, and consider pinning a digest as well: Docker's guidance says a digest pin guarantees the same image even if a tag is republished.

A multi-stage Dockerfile for a Node.js API

Docker's best-practice guide says to split a Dockerfile into stages so the final image contains only what the application needs, to use USER for a non-root user when a service can run without privileges, and to write CMD in exec form. The three stages below install all dependencies to compile TypeScript, install production dependencies separately, and copy only the results into the runtime image. COPY --chown matters because, without it, copied files are owned by UID and GID 0.

# syntax=docker/dockerfile:1.7
ARG NODE_VERSION=26

FROM node:${NODE_VERSION}-alpine AS build
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci
COPY tsconfig.json tsconfig.build.json ./
COPY src ./src
RUN npm run build

FROM node:${NODE_VERSION}-alpine AS prod-deps
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci --omit=dev && npm cache clean --force

FROM node:${NODE_VERSION}-alpine AS runtime
ENV NODE_ENV=production
WORKDIR /app
COPY --from=prod-deps --chown=node:node /app/node_modules ./node_modules
COPY --from=build --chown=node:node /app/dist ./dist
COPY --chown=node:node package.json ./
USER node
EXPOSE 3000
CMD ["node", "dist/server.js"]

npm ci installs exactly what the lockfile says and fails if the lockfile and package.json disagree, which is what a build should do. The supply-chain settings in protecting a Node.js project from npm supply-chain attacks apply inside the build as well.

Non-root, signals and the start command

The Node Docker best-practices guide says containers run as root by default, which violates least privilege, and that the Node images include a node user (UID 1000) that you select with USER node. It also says Node.js was not designed to run as PID 1 and may ignore signals such as SIGINT, so run with docker run --init, which wraps the process in Tini, or include Tini in the image. Finally, it recommends putting node directly in CMD instead of using npm start, so that SIGTERM reaches your process rather than being swallowed by npm. A graceful shutdown handler in your server then gets its chance to finish in-flight requests before Docker stops the container.

A healthcheck that does not need curl

The HEALTHCHECK instruction runs a command inside the container. Its options and defaults, from the Dockerfile reference, are --interval=30s, --timeout=30s, --start-period=0s, --start-interval=5s and --retries=3. Exit code 0 means healthy, 1 means unhealthy, and 2 is reserved. Only one HEALTHCHECK counts per Dockerfile. Docker's example uses curl, but the slim and Alpine images are minimal, so do not assume curl exists. Node can do the request itself, using the built-in fetch:

HEALTHCHECK --interval=30s --timeout=5s --start-period=20s --retries=3 \
  CMD node -e "fetch('http://127.0.0.1:3000/health/live').then(r=>process.exit(r.ok?0:1)).catch(()=>process.exit(1))"

Keep that endpoint to liveness: it answers 200 if the process can serve a request, without touching the database. Check dependencies on a separate readiness endpoint that your load balancer or Compose setup can use. Docker Compose can then wait for a healthy container with depends_on and condition: service_healthy.

Angular SSR: build once, run the server bundle

An Angular SSR build produces a browser folder and a server entry. In my own project the start script is node dist/website/server/server.mjs, and the generated server.ts reads the port from the PORT variable and falls back to 4000, so check yours before you set EXPOSE. Replace my-app below with your project name.

# syntax=docker/dockerfile:1.7
ARG NODE_VERSION=26

FROM node:${NODE_VERSION}-slim AS build
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci
COPY . .
RUN npm run build

FROM node:${NODE_VERSION}-slim AS runtime
ENV NODE_ENV=production PORT=4000
WORKDIR /app
COPY --from=build --chown=node:node /app/dist/my-app ./dist/my-app
USER node
EXPOSE 4000
CMD ["node", "dist/my-app/server/server.mjs"]

I copy only dist into the runtime image here. Whether that is enough depends on your server: I did not find a primary Angular source that guarantees the server bundle includes every package, and examples online differ. Run the image locally. If it fails with a missing module (for example because you added a custom Express file that imports packages), copy package.json and the lockfile and run npm ci --omit=dev in the runtime stage as well. Angular 22 itself needs Node 22.22.3 or later, 24.15.0 or later, or 26.0.0 or later, so do not pick an older base image. The framework side of the upgrade is covered in Upgrading to Angular 22.

allowedHosts and proxy headers behind nginx

This is the part people skip. Angular's security guide says the SSR engine checks the Host, X-Forwarded-Host and Forwarded host values against an allowlist, and answers 400 Bad Request for hostnames that are not allowed. You configure it under security.allowedHosts in angular.json, with the allowedHosts option of AngularNodeAppEngine, or with the NG_ALLOWED_HOSTS environment variable, a comma-separated list. A bare * accepts any host, which the guide warns can enable host header injection and SSRF, and calls reasonable only when a load balancer or proxy validates the Host header. By default the Forwarded header and all X-Forwarded-* headers are treated as untrusted and removed. You opt in with trustProxyHeaders (or the NG_TRUST_PROXY_HEADERS variable), and the guide says to do that only behind a trusted proxy that validates or overrides those headers, otherwise attackers can spoof them. Without it, an SSR app behind nginx can build absolute URLs with the wrong scheme and host. The 2026 advisories that make this more than theory are listed in Patching a MEAN Stack.

nginx in front

nginx terminates TLS, serves compressed static files, and forwards the rest to the Node container. Two details from the nginx documentation: by default the Host header is not passed on (nginx sends $proxy_host), so set it yourself, and since nginx 1.29.7 proxy_http_version defaults to 1.1, where earlier versions used 1.0, so set it explicitly if you may run older builds. Because this nginx sets the forwarded headers itself, overriding anything a client sent, it is the kind of proxy the Angular guide says may be trusted.

server {
  listen 8080;
  server_name example.com www.example.com;

  location / {
    proxy_pass http://app:4000;
    proxy_http_version 1.1;
    proxy_set_header Host $host;
    proxy_set_header X-Forwarded-Host $host;
    proxy_set_header X-Forwarded-Proto $scheme;
    proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
  }
}

To run nginx without root, the nginx/nginx-unprivileged image runs it as a non-root user. Its README lists the changes: the default port moves from 80 to 8080, the user directive is removed, and the PID file and temp paths move under /tmp. The older nginxinc/nginx-unprivileged repository on Docker Hub is archived and points to the new one. A Compose file ties it together, with only nginx reachable from outside:

services:
  app:
    build: .
    environment:
      NG_ALLOWED_HOSTS: example.com,www.example.com
    restart: unless-stopped        # no ports: only nginx can reach it

  web:
    image: nginx/nginx-unprivileged   # pin a tag or digest in real use
    ports:
      - '80:8080'
    volumes:
      - ./nginx.conf:/etc/nginx/conf.d/default.conf:ro
    depends_on:
      - app

For TLS, terminate it in nginx with certificates from your CA tooling, or at a load balancer in front. I cover the full VPS setup in my Docker and nginx deployment work. Docker's node documentation also notes that containers can use unlimited CPU and RAM by default, so set a memory limit (its example is -m 300M) when several containers share a host.

Secrets and .dockerignore

Docker's documentation says build arguments and environment variables are inappropriate for passing secrets to a build, because they persist in the image. Use a secret mount, which exists only for the duration of one RUN step and appears by default as a file under /run/secrets/<id>, or at the path you give with target=. A private registry token for npm ci is the usual case:

RUN --mount=type=secret,id=npmrc,target=/root/.npmrc npm ci
# docker build --secret id=npmrc,src=$HOME/.npmrc .

Runtime secrets such as JWT_SECRET, the MongoDB URI or a Stripe key should come from the environment, an env_file kept out of git, or your platform's secret store, never from the Dockerfile. A .dockerignore keeps them out of the build context too:

node_modules
dist
.git
.env
.env.*
*.log
Dockerfile
docker-compose*.yml

Scan the image and rebuild often

Docker's guidance is to rebuild images regularly to pick up security fixes, adding --pull to refresh the base image. Add a scan step to CI. Docker Scout builds an SBOM of the image and matches it against a vulnerability database. Its docker scout cves command has --only-severity, --only-fixed and --exit-code, which makes the command exit with code 2 when it finds vulnerabilities. Trivy offers --severity, --ignore-unfixed (show only fixed vulnerabilities) and --exit-code.

docker build --pull -t myapp:ci .

# Docker Scout: fail on fixable critical or high findings
docker scout cves --only-severity critical,high --only-fixed --exit-code myapp:ci

# Trivy: the same idea
trivy image --severity HIGH,CRITICAL --ignore-unfixed --exit-code 1 myapp:ci

Scanners only see packages in the image. They do not replace patching Node itself, so follow the Node.js security releases and the lists in Patching a MEAN Stack, and rebuild after each one.

Checklist

  • Multi-stage build, npm ci, production dependencies only in the runtime stage.
  • Node version pinned through an ARG, an LTS line, rebuilt with --pull.
  • USER node, COPY --chown, exec-form CMD ["node", ...], Tini or --init.
  • A Node-based HEALTHCHECK on a liveness endpoint.
  • nginx in front, the app container not published, NG_ALLOWED_HOSTS set, proxy headers trusted only behind that nginx.
  • No secrets in ARG, ENV or the image; a .dockerignore that excludes .env.
  • An image scan in CI that can fail the build.

Short FAQ

Is Alpine less secure than slim?

Not inherently. It is smaller, but it uses musl and omits common tools, which can cause native-module problems. Choose by compatibility first, and scan whichever you pick.

Do I need nginx if Angular already runs an Express server?

Not strictly. The SSR server can serve the browser files. I still put nginx in front for TLS termination, compression, caching and request limits, and so that only one container is exposed. That is my preference, not a requirement.

Why not just run npm start?

Because npm becomes an extra process between Docker and your server, and the Node image guide says it can swallow SIGTERM. Run node directly.

Sources

All pages were checked on 5 November 2026.