Deployment & Containers

This content was generated with the assistance of AI and should be verified against the official documentation before being relied on in production.

The reference material consulted while preparing these pages is listed in the section bibliography.

This page is written against Express 5.3 (npm express 5.3.0, published 2026-10-09) on Node.js 18 or later, the minimum for Express 5, with Node.js 24 as the runtime of the container examples (Active LTS when checked; Node.js 26 became LTS on 2026-10-28, and the same files build on it by changing one build argument). Express 4 (latest 4.22.3) is deployed in exactly the same way: nothing in a container or a pipeline depends on the Express major version. Everything was checked against the official documentation on 2026-10-29. Deploying an Express application is mostly deploying a Node.js process, so the page covers only what changes around the framework: an image that is small, runs as an unprivileged user and stops cleanly; a pipeline that proves the image works before it is published; and the choice of a place to run it. The examples use ESM and the node: prefix for built-in modules, and contain placeholders only, never real secrets. The runtime side of the story is in Production Deployment and Node.js in Docker and containers; the Express-specific side of shutdown and probes is in Health checks and graceful shutdown, and the proxy settings every platform below needs are in Behind proxies.

What the application must provide

A container platform treats the application as a black box with a small contract. The Bookshelf API built in the other pages of this section already honours it, which is why no code changes are needed to containerise it:

Contract What the application does

Configuration from the environment

PORT, NODE_ENV, DATABASE_FILE and SESSION_SECRET are read by the validated configuration module of Project Structure & Configuration. The image contains no .env file: values are injected when the container starts.

Listen on the platform port

The server reads PORT and listens on all interfaces. Cloud Run, Render, Railway and Heroku-style platforms choose the port and pass it in; Node.js binds to all interfaces by default, so no host argument is needed.

Stop on SIGTERM

Every orchestrator asks for a stop with SIGTERM and sends SIGKILL after a grace period. The server closes the listener, drops idle keep-alive connections and exits, as shown in Health checks and graceful shutdown.

Log to standard output

The process writes one line per event to stdout and stderr; the platform collects and ships them. No log files, no rotation inside the container. See Debugging & Logging.

Answer a health probe

A cheap endpoint such as /healthz returns 200 while the process can serve traffic, so Docker, Kubernetes and the load balancer can tell a running process from a working one.

Trust the proxy in front

Behind a load balancer the application sets the trust proxy setting, otherwise req.ip, req.protocol and secure cookies are wrong. See Behind proxies.

A production Dockerfile for Bookshelf

The image is built in two stages. The first installs only the production dependencies with npm ci --omit=dev, which installs exactly what package-lock.json pins and fails if the lock file and package.json disagree. The second starts from a clean base image, copies in the dependencies and the source, and switches to the node user that every official Node.js image already defines (uid 1000). Nothing from the first stage except node_modules reaches the final image, so the npm cache, build tools and any development dependency stay behind.

# syntax=docker/dockerfile:1

# One place to choose the Node.js line; override with --build-arg NODE_VERSION=26.
ARG NODE_VERSION=24

# ---- Stage 1: production dependencies only ----
FROM node:${NODE_VERSION}-slim AS deps
WORKDIR /app
COPY package.json package-lock.json ./
RUN --mount=type=cache,target=/root/.npm \
    npm ci --omit=dev

# ---- Stage 2: the runtime image ----
FROM node:${NODE_VERSION}-slim AS runtime

# Express and many libraries switch to faster code paths when NODE_ENV is production.
ENV NODE_ENV=production \
    PORT=8080 \
    DATABASE_FILE=/data/bookshelf.db

WORKDIR /app
COPY --from=deps /app/node_modules ./node_modules
COPY package.json ./
COPY src ./src

# A writable directory for the SQLite file; mount a volume here.
RUN mkdir -p /data && chown node:node /data

USER node
EXPOSE 8080

# The slim image has no curl or wget, so the probe uses the fetch built into Node.js.
HEALTHCHECK --interval=30s --timeout=3s --start-period=10s --retries=3 \
  CMD ["node", "-e", "fetch('http://127.0.0.1:' + process.env.PORT + '/healthz').then((r) => process.exit(r.ok ? 0 : 1)).catch(() => process.exit(1))"]

# Exec form: Node.js is PID 1 and receives SIGTERM directly, with no shell in between.
CMD ["node", "src/server.js"]

The choices in the file are each a small decision with a reason:

  • node:24-slim as the base. The slim variants are Debian-based without the compilers of the full image, a good default for an application with no native add-ons. The Alpine variants are smaller but use musl instead of glibc, which breaks some native modules and changes DNS behaviour; use them only after testing. Pin a major line (24) and rebuild regularly to receive base-image security fixes; for reproducibility pin the digest as well. The variants and their trade-offs are compared in Node.js in Docker and containers.

  • npm ci --omit=dev, not npm install. npm ci is the command meant for automated environments: it never rewrites the lock file and removes node_modules first. --omit=dev replaced the older --production flag. A TypeScript project adds a build stage that runs the compiler with the development dependencies and copies only the emitted dist directory into the runtime stage (see TypeScript with Express).

  • Copy package.json and the lock file before the source. Docker caches each instruction. Dependencies change less often than code, so the expensive install layer is reused until a dependency changes. The BuildKit cache mount keeps the npm download cache between builds without storing it in a layer.

  • The node user. A process that runs as root inside the container is root on the kernel if it escapes. The code and node_modules are copied without --chown, so they stay owned by root and are only readable by the application; only /data is handed to node, so a compromised handler cannot rewrite the code. Kubernetes can enforce the same with runAsNonRoot and a read-only root file system.

  • Exec-form CMD. With the shell form (CMD node src/server.js) the shell becomes PID 1 and may not forward SIGTERM, so the graceful shutdown never runs and the container is killed after the grace period. With npm start the same happens, because npm does not relay signals reliably. Run node directly. Because the application handles SIGTERM itself, an init process is optional; docker run --init (or init: true in Compose) adds a tiny one that also reaps zombie processes if the application spawns children.

  • No secrets in the image. Neither ENV SESSION_SECRET=<value> nor COPY .env belongs in the file, because anyone with the image can read both. Pass secrets when the container starts, or mount them as files, as shown below.

The .dockerignore file keeps the build context small and, more importantly, keeps local files out of the image:

node_modules
npm-debug.log
.git
.github
.env
.env.*
!.env.example
*.db
Dockerfile
compose.yaml
coverage
dist
test

Build and run the image locally with a placeholder secret (a real one comes from a secret manager, see below):

docker build -t bookshelf-api:dev .

docker run --rm --init -p 8080:8080 \
  -e SESSION_SECRET=replace-with-at-least-32-random-characters \
  -v bookshelf-data:/data \
  bookshelf-api:dev

curl -i http://localhost:8080/healthz
docker ps   # the STATUS column shows (healthy) once the HEALTHCHECK passes

The HEALTHCHECK is evaluated by the Docker engine and by Docker Compose. Kubernetes ignores it and uses its own probes, and Cloud Run uses its own startup and liveness checks, so the instruction is mainly useful on a single host. The details of liveness versus readiness are in Health checks and graceful shutdown; more image hardening (vulnerability scanning, digest pinning, provenance) is in Image best practices and security and the cache and multi-stage mechanics in Build cache and multi-stage builds.

Docker Compose for a single host

Compose describes the same container, its volume and its environment in one file. It is the right tool for local development against the production image and for small single-host deployments.

# compose.yaml
services:
  api:
    build: .
    image: bookshelf-api:dev
    init: true
    restart: unless-stopped
    ports:
      - "8080:8080"
    environment:
      NODE_ENV: production
      PORT: "8080"
      DATABASE_FILE: /data/bookshelf.db
    env_file:
      - .env            # holds SESSION_SECRET and other secrets; never committed
    volumes:
      - bookshelf-data:/data
    read_only: true     # the code and node_modules cannot be modified at run time
    tmpfs:
      - /tmp
    cap_drop:
      - ALL
    security_opt:
      - no-new-privileges:true

volumes:
  bookshelf-data:

The built-in node:sqlite module keeps the data in one file, so the named volume is the database and must be backed up. When the application uses a networked database instead (PostgreSQL, MySQL or MongoDB, see Database Integration and MongoDB & Mongoose), add it as a second service, point the application at it by service name and use depends_on with condition: service_healthy so the API starts after the database accepts connections. The full set of Compose features is covered in Compose for local development and Compose in practice. Compose is not an orchestrator: it does not roll out new versions without downtime or reschedule a container when its host fails.

Configuration and secrets in production

Following the twelve-factor model, the same image runs everywhere and only the environment differs. The configuration module of the Bookshelf API already refuses to start in production without a long SESSION_SECRET, which turns a forgotten secret into an immediate, visible deployment failure instead of a silent weakness.

Mechanism Notes

Environment variables

Simplest and portable. Visible to anyone who can run docker inspect or read /proc/<pid>/environ on the host, and inherited by child processes. Acceptable when access to the platform is controlled.

Secret manager injected by the platform

Cloud Run (Secret Manager), Kubernetes Secret objects (ideally synchronised from an external manager), Render and Fly.io secrets. The value reaches the process as an environment variable or a mounted file and never enters the image or the Git history.

Files mounted at run time

Docker and Compose secrets and Kubernetes volumes appear as files, such as /run/secrets/session_secret. Read them with readFileSync during start-up, or use the _FILE suffix convention in the configuration module. Files are not inherited by child processes.

node --env-file

Loads a .env file. Useful in development and in a VM deployment where the file is created by the deployment tooling with restrictive permissions. Do not copy the file into the image.

Continuous integration with GitHub Actions

A pipeline for the Bookshelf API has two jobs: prove that the code works (install, lint, test) and, only on the main branch, build the image, prove that the image starts and publish it. The tests import the application factory and call it without listening on a port, as described in Testing, so the job needs no server and no network.

flowchart LR A["Push or pull request"] --> B["Install with npm ci"] B --> C["Lint and test"] C -->|"pull request"| D["Report status"] C -->|"push to main"| E["Build image with Buildx"] E --> F["Start the image and probe /healthz"] F --> G["Push to the registry"] G --> H["Deploy: Cloud Run, Kubernetes or another platform"]
# .github/workflows/ci.yml
name: CI

on:
  pull_request:
  push:
    branches: [main]

permissions:
  contents: read

concurrency:
  group: ci-${{ github.ref }}
  cancel-in-progress: true

jobs:
  test:
    runs-on: ubuntu-latest
    strategy:
      matrix:
        node-version: [22, 24]
    steps:
      - uses: actions/checkout@v7
      - uses: actions/setup-node@v7
        with:
          node-version: ${{ matrix.node-version }}
          cache: npm
      - run: npm ci
      - run: npm run lint --if-present
      - run: npm test

  image:
    needs: test
    if: github.event_name == 'push' && github.ref == 'refs/heads/main'
    runs-on: ubuntu-latest
    permissions:
      contents: read
      packages: write
    steps:
      - uses: actions/checkout@v7
      - uses: docker/setup-buildx-action@v4

      - name: Build the image for the smoke test
        uses: docker/build-push-action@v7
        with:
          context: .
          load: true
          tags: bookshelf-api:ci
          cache-from: type=gha
          cache-to: type=gha,mode=max

      - name: Smoke test the image
        run: |
          docker run -d --name api -p 8080:8080 \
            -e SESSION_SECRET=ci-only-placeholder-secret-0123456789 \
            bookshelf-api:ci
          for i in $(seq 1 15); do
            curl --fail --silent http://localhost:8080/healthz && exit 0
            sleep 1
          done
          docker logs api
          exit 1

      - uses: docker/login-action@v4
        with:
          registry: ghcr.io
          username: ${{ github.actor }}
          password: ${{ secrets.GITHUB_TOKEN }}

      - id: meta
        uses: docker/metadata-action@v6
        with:
          images: ghcr.io/${{ github.repository }}
          tags: |
            type=sha
            type=raw,value=latest

      - uses: docker/build-push-action@v7
        with:
          context: .
          push: true
          tags: ${{ steps.meta.outputs.tags }}
          labels: ${{ steps.meta.outputs.labels }}
          cache-from: type=gha

The points that matter in the pipeline:

  • Test on the supported Node.js lines. A matrix of the Active LTS and the Maintenance LTS catches version-specific failures; the container uses one of them. Express 5 requires Node.js 18 or later, but test only the lines that are still supported by Node.js itself.

  • npm ci plus the cache: npm option of setup-node caches the download cache, not node_modules, and keys it on the lock file.

  • Least privilege. The workflow declares permissions: contents: read and grants packages: write only to the job that pushes. GITHUB_TOKEN is enough for ghcr.io, so no registry password is stored.

  • Build once, test what you ship. The smoke test runs the exact image that is then pushed, so a broken CMD, a missing file or a failing configuration check is caught before publication. Without load: true the image would stay in the Buildx cache and docker run would not find it.

  • Pin actions. The tags above are major versions of official actions; supply-chain-conscious projects pin the commit SHA and let Dependabot propose updates.

  • Deployment is a separate concern. Pushing an immutable sha tag lets the platform deploy a precise version and roll back by deploying the previous one; latest is a convenience for humans, never the reference a platform pulls.

The same build, scan and release steps are explained in more depth in Docker CI/CD with GitHub Actions, and a GitOps approach where the pipeline only updates a manifest is in GitOps and CI/CD.

Where to run it

Because the contract above is the same everywhere, the choice of platform is about operations and cost, not code.

Option Fits when What to know

Google Cloud Run

Request-driven HTTP services that can scale to zero and need no cluster to manage.

Deploys the container above unchanged and injects PORT. Instances are stateless and may be created or stopped at any time, so the local SQLite file is only a cache or a demo: use a managed database. Handle SIGTERM (Cloud Run gives a short grace period), set the trust proxy setting, and tune concurrency and minimum instances for cold starts. See Cloud Run and Cloud Run scaling, traffic and jobs.

Kubernetes

Many services, rolling updates, autoscaling and a platform team.

A Deployment with readiness and liveness probes that call the health endpoints, resource requests and limits, a terminationGracePeriodSeconds longer than the application shutdown timeout and a non-root security context. See ReplicaSets and Deployments, Health probes and lifecycle and Bookshelf end to end.

Render, Fly.io, Railway

Small teams that want Git-push deployment, managed databases and TLS without operating infrastructure.

Each builds from a Dockerfile (or detects a Node.js project and runs npm start), injects PORT and offers managed PostgreSQL or volumes. Check each provider’s current free tier and pricing: free tiers change often, which is the lesson of Heroku below.

AWS Lambda and other serverless functions

Spiky, low-volume traffic where per-request billing beats an always-on process.

An Express application is not a function handler, so an adapter translates events into requests: packages such as serverless-http or @codegenie/serverless-express, or the Lambda Web Adapter, which runs an unchanged HTTP server inside a container image. Long-lived connections (WebSockets, Server-Sent Events) and in-memory state do not fit, and each cold start pays the start-up cost of the application.

A virtual machine

One or two servers, full control, existing operations practices.

Run the container with Docker or Podman, or run Node.js directly under systemd. Put Nginx, Caddy or a cloud load balancer in front for TLS and set trust proxy accordingly.

The Express project’s own production advice stays valid on every one of these platforms: set NODE_ENV=production, compress at the proxy where possible, avoid synchronous calls, log to standard output, handle exceptions, and use a process manager or orchestrator to restart the process after a crash. See Performance best practices and Security Best Practices.

Process managers, init systems and orchestrators

Something must restart the process when it crashes, and something must give it more than one CPU core. Each layer has a different tool:

Tool Role today

nodemon, node --watch

Development only. node --watch is built into Node.js and restarts on file changes; nodemon does the same as a separate package. Neither belongs in a production image.

forever

A simple restarter that is rarely maintained. Prefer a supervisor that also reports status and handles signals.

systemd

The standard init system of Linux servers. A unit with Restart=on-failure, User=, NoNewPrivileges=true and EnvironmentFile= restarts the process and applies sandboxing without extra packages.

PM2

A process manager with clustering, log handling and zero-downtime reloads, useful on a VM without containers. In a container it duplicates what the orchestrator already does, so run a single node process per container instead.

Orchestrator (Kubernetes, Cloud Run, ECS, Nomad)

Restarts failed containers, runs several replicas for both availability and throughput, and drains them on rollout. Scale by adding replicas instead of forking workers with node:cluster inside each container.

The decision rule is short. On a container platform, run one node process per container and let the platform restart and scale it. On a plain VM, run the container under Docker’s restart policy, or Node.js under systemd, and add PM2 or node:cluster only when one process cannot use the machine’s cores. More on this is in Cluster and scaling.

What changed recently

  • Express 5 changes nothing in packaging. The framework is a pure JavaScript package; the same Dockerfile builds Express 4 and Express 5 applications. What does change is the minimum runtime: Express 5 needs Node.js 18, while current images and platforms run Node.js 22, 24 and later.

  • Node.js 26 became LTS on 2026-10-28. Plan the base-image upgrade with the build argument above, test on both lines in the pipeline, and update the platform’s runtime setting at the same time.

  • --env-file, --watch and a built-in test runner in Node.js remove the need for dotenv, nodemon and, often, Jest in simple projects, which shortens both images and pipelines.

  • BuildKit is the default builder, so cache mounts, secret mounts and the # syntax= directive work without extra setup.

  • Security releases of Express are frequent in 2026. Rebuild and redeploy on every advisory; a pipeline that builds on a schedule picks up base-image and dependency fixes even when the code does not change. Enable Dependabot or a similar tool for both npm and the Docker base image.

Where the books are outdated

The consulted books deploy Express applications to the platforms that were common when they were written. The techniques have been replaced, and some of the services no longer exist.

Practice found in the books Today

Heroku free dynos with a Procfile and git push heroku

The free dynos ended in November 2022. Heroku still exists as a paid platform and supports Node.js and container deploys; Render, Fly.io, Railway and Cloud Run are the usual alternatives. Behind any of them, set the trust proxy setting, which the books omit.

mLab / MongoLab and MongoHQ / Compose as hosted MongoDB

These services are gone. MongoDB Atlas is the hosted offering, and a MongoDB container works for local development.

Travis CI (travis-ci.org), Shippable and Codeship pipelines

The travis-ci.org service was shut down, Shippable was discontinued and Codeship is legacy. GitHub Actions, GitLab CI and similar hosted runners replaced them, with the workflow stored in the repository.

forever or nodemon to keep the server alive

Not recommended in production. Use systemd, a container restart policy or an orchestrator; use node --watch in development.

npm install or npm install --production in the deploy script

npm ci --omit=dev against a committed lock file, ideally inside a multi-stage image build.

Starting the server with npm start or node app.js under a shell

Exec-form node as PID 1 so SIGTERM reaches the application and the shutdown handler runs.

Environment files or secrets committed to the repository, or set once in a dashboard and forgotten

Secrets come from a secret manager or the platform at run time and are never copied into the image or the Git history.

Common pitfalls

  • Shell-form CMD or npm start as the entrypoint. The graceful shutdown never runs and every rollout drops in-flight requests after the grace period.

  • A missing .dockerignore. The build context uploads node_modules and .git, and a local .env can end up in the image.

  • Running as root. Add USER node after the copy instructions and fix ownership of any writable directory.

  • Installing development dependencies in the runtime stage. The image grows and carries test tooling that widens the attack surface.

  • Storing state in the container file system. Containers are replaced on every deployment; sessions, uploads and the database need a volume or an external service (see Cookies & Sessions and File Uploads & Downloads).

  • A HEALTHCHECK or probe that depends on the database. A database outage then restarts every API container at the same time. Use a liveness check that only proves the process responds and a separate readiness check.

  • Forgetting trust proxy. Behind a load balancer the client IP is the balancer’s, rate limiting hits everyone at once, and secure cookies are not issued because the application sees plain HTTP.

  • Using latest as the deployed tag. Roll-backs and audits need an immutable tag or a digest.

  • Building different images for different environments. Build once, promote the same digest through staging and production, and vary only the configuration.