ExisJS

Deploying with Docker

This page builds an ExisJS app into a production image that:

  • builds without secrets, because container platforms provide them only when the container runs;
  • refuses to start when a required secret is missing;
  • runs the server as one process that stops gracefully on docker stop and on every redeploy;
  • contains no build tools: no TypeScript, esbuild or CLI.

The Dockerfile below was tested end to end with an app created by npm create exisjs.


The Dockerfile

Dockerfile
# syntax=docker/dockerfile:1
# ── Build: full toolchain ────────────────────────────────────────────────────FROM node:24-slim AS buildWORKDIR /appCOPY package*.json ./RUN npm ciCOPY . .# Secrets are injected when the container runs, not while the image buildsRUN npx exisjs build --skip-env-check
# ── Runtime: production dependencies only ────────────────────────────────────FROM node:24-slimWORKDIR /appENV NODE_ENV=production PORT=3000COPY package*.json ./RUN npm ci --omit=dev --omit=optional && npm cache clean --forceCOPY --from=build /app/.exis ./.exisUSER nodeEXPOSE 3000HEALTHCHECK --interval=30s --timeout=5s --start-period=10s \  CMD node -e "fetch('http://127.0.0.1:3000/health').then(r => process.exit(r.ok ? 0 : 1), () => process.exit(1))"CMD ["node_modules/.bin/exisjs", "start"]

exisjs build writes everything the server needs into .exis/: the compiled code, the route manifest, the compiled exis.config and main.js, the entry that starts the server. The runtime image needs only .exis/, package.json and the production dependencies; it contains no source files.

  • --omit=optional keeps the CLI out of the image. Until 0.11, exisjs lists @exisjs/cli as an optional dependency so that projects created before 0.10 keep working. If you use the uWebSockets.js engine (server: 'uws'), install it in the project (npm install https://github.com/uNetworking/uWebSockets.js/archive/refs/tags/v20.71.0.tar.gz): exisjs doesn't install it. uWebSockets.js is not published on npm; this URL is its GitHub release, which needs no git in the image. Its Linux binary needs glibc 2.38 or later, which node:24-slim (Debian 12, glibc 2.36) doesn't have: use node:24-trixie-slim in both stages instead. On node:24-slim, the server logs uWebSockets.js failed to load and runs on Node.
  • npm ci in the build stage installs devDependencies, which include @exisjs/cli and typescript.
  • Use a clean npm ci --omit=..., not npm prune. Both exisjs and @exisjs/cli provide the exisjs command. When npm prune --omit=dev --omit=optional removes the CLI, npm also removes the command and doesn't link the runtime's, so exisjs start is not found. If your build prunes, run npm rebuild exisjs after it, or start with node .exis/server/main.js, which needs no command.

Add a .dockerignore, so local files never reach the image. .env must be in it, or your local secrets are copied into every image layer:

.dockerignore
node_modules.exisdist.git.env.env.*!.env.example*.logcoverage

Secrets: --skip-env-check

exisjs build loads your route files to check them, and a route that imports tex.env validates the environment while it loads. In a Docker build the secrets don't exist yet, so a plain RUN npx exisjs build fails:

✗ BUILD_VALIDATION_FAILED: Validation Error: DATABASE_URL: Expected value, received undefined

--skip-env-check skips that check for the build only. When the container starts, tex.env validates the real environment, and a missing or invalid value stops the server with exit code 1:

[ExisJS] the routes manifest failed to load, so the server will not start:Validation Error: DATABASE_URL: Expected value, received undefined

Pass the secrets when you run the container (docker run -e DATABASE_URL=..., or your platform's environment settings). Never set EXIS_SKIP_ENV_CHECK in the runtime environment: it is the build flag, and would let the server start with placeholder values.


One process, graceful shutdown

exisjs start comes with the exisjs runtime, so the image needs no CLI, and it runs the server in its own process: Node is PID 1 in the container and receives SIGTERM directly. (node .exis/server/main.js, written by exisjs build, does the same.)

In the CMD, call the binary itself rather than npm start: with npm in between, a redeploy's SIGTERM shows up as npm error signal SIGTERM, and some platforms stop the container early when they detach its input. npm start with "start": "exisjs start" works too, for platforms that only run npm scripts.

On docker stop:

  1. If readiness probes are on (healthcheck: { enabled: true }), /_ready answers 503 while the server keeps accepting requests for healthcheck.drainMs (default 5 seconds in production). That gives the load balancer time to stop sending traffic here. Ctrl+C in a terminal skips this wait.
  2. The server stops accepting new connections.
  3. Requests in progress get up to 5 seconds to finish.
  4. onClose hooks run and registered databases disconnect.
  5. The process exits with code 0.

Docker waits 10 seconds by default before it kills the container. With readiness probes on, the drain and the requests can take up to 10 seconds together, so give the container more time: docker stop -t 30 / stop_grace_period: 30s in Compose, and the platform's equivalent (terminationGracePeriodSeconds in Kubernetes).

With cluster: { workers } set, that process is the cluster primary and forks the workers itself.


Health checks

The project template has a GET /health route, which the HEALTHCHECK above calls. node:24-slim has no curl, so the check uses Node's fetch.

For a load balancer or Kubernetes, turn on the built-in probes instead:

exis.config.ts
export default defineConfig({  healthcheck: { enabled: true }, // GET /_health (liveness) and GET /_ready (readiness)})

Point the load balancer, or the Kubernetes readinessProbe, at /_ready: it answers 503 while a registered database is down and during shutdown. Use /_health only to decide whether to restart the container.

On shutdown, /_ready answers 503 for drainMs before the server stops accepting connections. Set it to at least the probe's interval, so the load balancer notices before requests are refused:

exis.config.ts
export default defineConfig({  healthcheck: { enabled: true, drainMs: 10_000 }, // Kubernetes: periodSeconds 5 x failureThreshold 2})

Database migrations and scripts

The runtime image has no source files and no CLI, so run one-off tasks from the build stage, which has the whole project and toolchain:

docker build --target build -t my-api:build .docker run --rm -e DATABASE_URL="$DATABASE_URL" my-api:build npx exisjs run scripts/migrate.ts

Run it as a separate step of your deployment, before the new version starts.


Projects created before 0.10

Run npx exisjs migrate once. It adds @exisjs/cli to devDependencies and lists the Dockerfile lines to change (--omit=optional, and a CMD that goes through npm start). "start": "exisjs start" stays as it is: it now runs the server in its own process, without the CLI.


Platform notes

  • Pin the Node.js version. Use an explicit image (node:24-slim). Builders that pick Node for you, such as Nixpacks, may default to an older version than your dependencies need; set it explicitly (for Nixpacks, nodejs_22 or later in nixpacks.toml).
  • Builds on the host. Platforms that build without a Dockerfile must install devDependencies for the build (npm install --include=dev), because exisjs build comes from @exisjs/cli.
  • Start command. On platforms that ask for a start command instead of a Dockerfile CMD, enter npx exisjs start or npm start.
  • Long requests behind a CDN. A proxy such as Cloudflare ends requests after about 100 seconds, whatever the server allows. Serve long-running endpoints from a hostname that is not proxied.

Image size

Measured with an app created by npm create exisjs, on node:24-slim:

Before 0.100.10
Production node_modules45 MB8 MB
Build tooling in it (typescript, esbuild, tsx, the CLI)34 MBnone
Image274 MB237 MB
Node processes in the container21

Most of the remaining image is the node:24-slim base. The production install is checked in CI on every change, against a size budget that fails if build tooling returns.