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 |
|
Listen on the platform port |
The server reads |
Stop on |
Every orchestrator asks for a stop with |
Log to standard output |
The process writes one line per event to |
Answer a health probe |
A cheap endpoint such as |
Trust the proxy in front |
Behind a load balancer the application sets the |
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"]
Source: Docker: Building best practices
The choices in the file are each a small decision with a reason:
-
node:24-slimas 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, notnpm install.npm ciis the command meant for automated environments: it never rewrites the lock file and removesnode_modulesfirst.--omit=devreplaced the older--productionflag. A TypeScript project adds a build stage that runs the compiler with the development dependencies and copies only the emitteddistdirectory into the runtime stage (see TypeScript with Express). -
Copy
package.jsonand 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
nodeuser. A process that runs as root inside the container is root on the kernel if it escapes. The code andnode_modulesare copied without--chown, so they stay owned by root and are only readable by the application; only/datais handed tonode, so a compromised handler cannot rewrite the code. Kubernetes can enforce the same withrunAsNonRootand 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 forwardSIGTERM, so the graceful shutdown never runs and the container is killed after the grace period. Withnpm startthe same happens, because npm does not relay signals reliably. Runnodedirectly. Because the application handlesSIGTERMitself, an init process is optional;docker run --init(orinit: truein 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>norCOPY .envbelongs 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
Source: Docker: .dockerignore files
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:
Source: Docker: Compose Quickstart
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 |
Secret manager injected by the platform |
Cloud Run (Secret Manager), Kubernetes |
Files mounted at run time |
Docker and Compose secrets and Kubernetes volumes appear as files, such as |
|
Loads a |
Source: The Twelve-Factor App: Config
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.
# .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 ciplus thecache: npmoption ofsetup-nodecaches the download cache, notnode_modules, and keys it on the lock file. -
Least privilege. The workflow declares
permissions: contents: readand grantspackages: writeonly to the job that pushes.GITHUB_TOKENis enough forghcr.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. Withoutload: truethe image would stay in the Buildx cache anddocker runwould 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
shatag lets the platform deploy a precise version and roll back by deploying the previous one;latestis 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 |
Kubernetes |
Many services, rolling updates, autoscaling and a platform team. |
A |
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 |
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
|
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 |
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 |
|---|---|
|
Development only. |
|
A simple restarter that is rarely maintained. Prefer a supervisor that also reports status and handles signals. |
|
The standard init system of Linux servers. A unit with |
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 |
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 |
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,--watchand a built-in test runner in Node.js remove the need fordotenv,nodemonand, 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 |
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 |
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. |
|
Not recommended in production. Use |
|
|
Starting the server with |
Exec-form |
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
CMDornpm startas the entrypoint. The graceful shutdown never runs and every rollout drops in-flight requests after the grace period. -
A missing
.dockerignore. The build context uploadsnode_modulesand.git, and a local.envcan end up in the image. -
Running as root. Add
USER nodeafter 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
HEALTHCHECKor 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, andsecurecookies are not issued because the application sees plain HTTP. -
Using
latestas 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.
References
-
Express: Production best practices, performance and reliability
-
Publisher pages of the consulted books are listed in the section bibliography.
-
Related pages: Health checks and graceful shutdown, Behind proxies, Performance best practices, Testing, Production Deployment, Node.js in Docker and containers, Docker, Kubernetes