← website
Run it

Deployment

NotifKit ships as a library, not an image. You containerise your service — the one that constructs NotifkitServer — and give it a Postgres and a Redis. This page is that Dockerfile, a compose file to run it against, and the two mistakes that bite on the second replica.

note

There is no official notifkit/notifkit image to pull. The package is an npm dependency of a Node service you write, exactly like the server.ts from the quickstart. Everything below builds that service.

What you are building

The smallest correct production deployment is three containers. NotifKit will not start in production without the middle two — it throws at boot rather than silently degrading:

ContainerWhat it isRequired
notifkit Your Node service embedding NotifkitServer Yes
postgres Users, templates, delivery log, workflow state Yes — DATABASE_URL
redis The streams every worker reads from Yes — REDIS_URL
heads up

In development, omitting databaseUrl and redisUrl makes NotifKit start throwaway containers for you via testcontainers. That path is disabled when NODE_ENV=production — instead startup fails with Missing required configuration. If your container exits immediately on deploy, this is almost always why.

The service

Read both URLs from the environment and pass them in. Nothing else about server.ts changes between development and production except nodeEnv:

import { NotifkitServer } from "notifkit";
import { ResendTransport } from "@notifkit/provider-resend";

const server = new NotifkitServer({
  services: ["all"],
  port: Number(process.env.PORT ?? 3000),
  nodeEnv: "production",

  // Required in production — startup throws without them.
  databaseUrl: process.env.DATABASE_URL,
  redisUrl: process.env.REDIS_URL,

  // Transports must be registered on every deployment that runs `delivery`.
  providers: [new ResendTransport({ apiKey: process.env.RESEND_KEY!, from: "hi@acme.com" })],
});

await server.start();

Dockerfile

Multi-stage: build with dev dependencies, ship without them. NotifKit targets Node 22 and is ESM-only, so the base image is not negotiable below 22.

# ── build ───────────────────────────────────────────────────────
FROM node:22-alpine AS build
WORKDIR /app

COPY package*.json ./
RUN npm ci

COPY . .
RUN npm run build

# ── runtime ─────────────────────────────────────────────────────
FROM node:22-alpine AS runtime
WORKDIR /app
ENV NODE_ENV=production

# Production deps only. This still pulls in notifkit's `drizzle/`
# folder, which is where the migrations live — do not prune it.
COPY package*.json ./
RUN npm ci --omit=dev && npm cache clean --force

COPY --from=build /app/dist ./dist

# Do not run as root.
USER node

EXPOSE 3000

# Exec form, so node is PID 1 and receives SIGTERM directly.
# NotifkitServer installs its own SIGTERM/SIGINT handlers and drains
# in-flight work before exiting.
CMD ["node", "dist/server.js"]
gotcha

Do not wrap the command in npm start or sh -c. Both make the shell PID 1, and the shell will not forward SIGTERM to node — your container gets SIGKILLed after the grace period with messages still in flight. Use the exec form above.

node_modules
dist
.git
.env
*.log
tests
coverage

Compose: everything in one process

Right for staging and for production up to the point where one service's load starts interfering with another's. services: ["all"] runs the API and all seven workers in the single container:

services:
  notifkit:
    build: .
    ports:
      - "3000:3000"
    environment:
      NODE_ENV: production
      PORT: "3000"
      # Required. The default is 127.0.0.1, which would accept connections from
      # inside this container only — the published port above would refuse them.
      HOST: 0.0.0.0
      LOG_LEVEL: info
      DATABASE_URL: postgres://notifkit:notifkit@postgres:5432/notifkit
      REDIS_URL: redis://redis:6379
      ADMIN_API_KEY: ${ADMIN_API_KEY:?set ADMIN_API_KEY}
      RESEND_KEY: ${RESEND_KEY}
    depends_on:
      postgres:
        condition: service_healthy
      redis:
        condition: service_healthy
    # Node 22 has global fetch, so no curl needed in the image.
    healthcheck:
      test: ["CMD", "node", "-e", "fetch('http://localhost:3000/ready').then(r=>process.exit(r.ok?0:1)).catch(()=>process.exit(1))"]
      interval: 15s
      timeout: 5s
      retries: 5
      start_period: 40s
    # Above the 30s the delivery worker may need to drain.
    stop_grace_period: 45s
    restart: unless-stopped

  postgres:
    image: postgres:16-alpine
    environment:
      POSTGRES_USER: notifkit
      POSTGRES_PASSWORD: notifkit
      POSTGRES_DB: notifkit
    volumes:
      - pgdata:/var/lib/postgresql/data
    healthcheck:
      test: ["CMD-SHELL", "pg_isready -U notifkit"]
      interval: 10s
      timeout: 5s
      retries: 5
    restart: unless-stopped

  redis:
    image: redis:7-alpine
    # Persistence on: in-flight messages and pending lists live here.
    command: ["redis-server", "--appendonly", "yes"]
    volumes:
      - redisdata:/data
    healthcheck:
      test: ["CMD", "redis-cli", "ping"]
      interval: 10s
      timeout: 5s
      retries: 5
    restart: unless-stopped

volumes:
  pgdata:
  redisdata:
docker compose up --build -d
docker compose logs -f notifkit

# Once it reports healthy, bootstrap a project exactly as in the quickstart:
curl -s -X POST http://localhost:3000/v1/projects \
  -H "Authorization: Bearer $ADMIN_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{"name":"production"}'

Splitting the API from the workers

The services array is the only thing that differs between deployments. Point them at the same Postgres and Redis and they form one system — the API scales on request rate, the workers on stream depth. Both images are identical; only the command differs.

// One image, two roles, chosen at boot.
const ROLE = process.env.NOTIFKIT_ROLE ?? "all";

const services =
  ROLE === "api"     ? ["api"] as const
  : ROLE === "worker" ? ["enricher", "engine", "delivery", "scheduler",
                         "workflow", "events", "ai"] as const
  : ["all"] as const;

const server = new NotifkitServer({
  services: [...services],
  nodeEnv: "production",
  databaseUrl: process.env.DATABASE_URL,
  redisUrl: process.env.REDIS_URL,
  providers: [/* … */],

  // Only the api role owns migrations. See below.
  autoMigrate: ROLE !== "worker",
});
services:
  api:
    build: .
    environment:
      NOTIFKIT_ROLE: api
      NODE_ENV: production
      HOST: 0.0.0.0
      DATABASE_URL: postgres://notifkit:notifkit@postgres:5432/notifkit
      REDIS_URL: redis://redis:6379
      ADMIN_API_KEY: ${ADMIN_API_KEY:?set ADMIN_API_KEY}
    ports:
      - "3000:3000"
    depends_on:
      postgres: { condition: service_healthy }
      redis:    { condition: service_healthy }
    healthcheck:
      test: ["CMD", "node", "-e", "fetch('http://localhost:3000/ready').then(r=>process.exit(r.ok?0:1)).catch(()=>process.exit(1))"]
      interval: 15s
      start_period: 40s
    stop_grace_period: 45s

  worker:
    build: .
    environment:
      NOTIFKIT_ROLE: worker
      NODE_ENV: production
      DATABASE_URL: postgres://notifkit:notifkit@postgres:5432/notifkit
      REDIS_URL: redis://redis:6379
    depends_on:
      # Wait for the api to have migrated before consuming.
      api:
        condition: service_healthy
    deploy:
      replicas: 3
    # No HTTP server in this role — see the note below.
    stop_grace_period: 45s
heads up

/health, /live, /ready and /metrics are served by the api service. A worker-only container has no HTTP listener at all, so there is nothing to point a healthcheck at — omit it rather than fabricating one, and watch stream depth and the worker's own logs instead. If you need a worker probe, add "api" to that deployment's services and give it its own port.

Migrations and the second replica

autoMigrate defaults to true, and migrations run during server.start() before any service comes up. With one container that is exactly what you want. With three replicas booting at once, they race for the same schema lock.

Pick one owner and turn it off everywhere else:

DeploymentautoMigrateWhy
Single all-in-one container true (default) Nothing to race with
api, one replica true Boots first; workers wait on its healthcheck
api, several replicas false Run a dedicated migrate job before rollout
Any worker false Never owns schema

For the multi-replica case, run migrations as a one-shot container that exits, and gate the rollout on it:

  migrate:
    build: .
    command: ["node", "dist/migrate.js"]
    environment:
      NODE_ENV: production
      DATABASE_URL: postgres://notifkit:notifkit@postgres:5432/notifkit
      REDIS_URL: redis://redis:6379
    depends_on:
      postgres: { condition: service_healthy }
    restart: "no"
// Starts nothing. Migrates, then exits — the whole point.
import { NotifkitServer } from "notifkit";

const server = new NotifkitServer({
  services: [],
  nodeEnv: "production",
  databaseUrl: process.env.DATABASE_URL,
  redisUrl: process.env.REDIS_URL,
  autoMigrate: true,
});

await server.start();
await server.stop();
note

Migrations ship inside the package, at node_modules/notifkit/drizzle. If your image prunes node_modules after build, or copies only dist/, migration will fail at runtime with a missing-folder error. The Dockerfile above keeps the production dependency tree for exactly this reason.

Deploying on a PaaS

Railway, Render, Fly and Heroku all work, and they differ from the Compose setup above in three ways that matter: the platform supplies Postgres and Redis, it chooses the port, and it reaches your container over a network interface rather than loopback. Railway is the worked example below; the same three points carry to the others under different names.

What you actually deploy

Not this repository. NotifKit is a library — its package.json has no start script and its entry point is dist/index.mjs, so there is nothing here to boot. What you deploy is a small repository of your own holding the server.ts from above, a package.json that depends on notifkit and defines a start script, and nothing else:

{
  "type": "module",
  "engines": { "node": ">=22" },
  "scripts": {
    "build": "tsc",
    "start": "node dist/server.js"
  },
  "dependencies": {
    "notifkit": "^0.1.0",
    "@notifkit/provider-resend": "^0.1.0"
  }
}

A Dockerfile is optional here — Railway builds with Nixpacks when there is none, and the engines field above is what pins it to Node 22. If you add the Dockerfile from earlier, Railway uses that instead, and the note about keeping node_modules/notifkit/drizzle in the image applies exactly as it does under Compose.

The two dependencies

Add a Postgres and a Redis to the project, then reference them rather than pasting connection strings — Railway's ${{ Service.VAR }} syntax keeps them correct when a database is recreated:

VariableValueWhy
HOST0.0.0.0The one that bites. The default binds loopback and the platform router cannot reach it — see the gotcha below
NODE_ENVproductionBelow this, a missing URL makes start() reach for testcontainers, and there is no Docker daemon inside a PaaS container
DATABASE_URL${{ Postgres.DATABASE_URL }}Required in production
REDIS_URL${{ Redis.REDIS_URL }}Required in production
PORTLeave it unset. Railway injects it and the config schema reads it; overriding it breaks the healthcheck
PUBLIC_URLhttps://${{ RAILWAY_PUBLIC_DOMAIN }}Where an inbox reaches you. Needed for unsubscribe links and provider webhooks
UNSUBSCRIBE_SECRET16+ random charactersSet once and never rotate — see the warning below
ADMIN_API_KEYyour bootstrap keyOnly on the deployment that mints projects

Config as code

A railway.json at the repository root pins the parts that are easy to lose in a dashboard:

{
  "$schema": "https://railway.com/railway.schema.json",
  "deploy": {
    "startCommand": "npm start",
    "healthcheckPath": "/ready",
    "healthcheckTimeout": 300,
    "restartPolicyType": "ALWAYS",
    "drainingSeconds": 45
  }
}

drainingSeconds is the window between SIGTERM and SIGKILL — the same job stop_grace_period does under Compose, so keep it above the 30 seconds the delivery worker may need to drain.

/ready rather than /health: it reports on the dependencies, so a container that booted before Postgres was accepting connections fails the check and is replaced instead of serving errors. Both routes are unauthenticated and are served only where services includes api.

Migrations, without the race

The one-owner rule is the same here, and Railway gives you a cleaner way to honour it than a compose depends_on. Set autoMigrate: false on every service and hand migrations to preDeployCommand, which runs to completion before any new container starts taking traffic:

{
  "deploy": {
    "preDeployCommand": "node dist/migrate.js",
    "startCommand": "npm start"
  }
}

That is the same migrate.ts from the section above — the one that starts no services, migrates, and exits. Put it on the API service only; a worker service should never own schema.

Splitting into two services

The NOTIFKIT_ROLE pattern maps onto Railway directly: deploy the same repository twice in one project, set NOTIFKIT_ROLE=api on one and NOTIFKIT_ROLE=worker on the other, and point both at the same database references. Only the API service gets a public domain, a healthcheckPath, and HOST — a worker has no HTTP listener, so a healthcheck against it can only fail.

think before you generate a public domain

The CORS gotcha below is not theoretical on a PaaS, where one click gives the service a public hostname. Every project-scoped endpoint then answers any browser origin that holds a key, so a key that leaks into frontend code is directly exploitable rather than merely embarrassing.

If every caller is server-side, keep the service on the platform's private network and skip the public domain entirely. You need one only for inbound traffic that originates outside your infrastructure — one-click unsubscribe from a mail client, and provider webhooks delivering opens, bounces and complaints. Both are worth having, so this is a trade-off to make deliberately rather than a setting to leave at its default.

Environment

VariableDefaultNotes
NODE_ENVdevelopmentMust be production, or NotifKit tries to start testcontainers
DATABASE_URLRequired in production
REDIS_URLRequired in production
PORT3000Only meaningful where services includes api
HOST127.0.0.1Bind address. The default accepts loopback only — set 0.0.0.0 in any container whose port is reached from outside it. See below
LOG_LEVELinfofataltrace, or silent
ADMIN_API_KEYEnables POST /v1/projects. Leave unset on deployments that do not manage projects
PUBLIC_URLWhere an inbox can reach this API, e.g. https://notify.example.com. Not HOST/PORT, which are the bind address behind your proxy
UNSUBSCRIBE_SECRETSigns unsubscribe tokens, minimum 16 characters. Effectively permanent — see below
unsubscribe: set both, then leave them alone

One-click unsubscribe headers are attached only when PUBLIC_URL and UNSUBSCRIBE_SECRET are both set. With either missing mail still sends, just without them — which mailbox providers penalise on bulk sends, and that reputation hit lands on the same domain your transactional mail uses.

Tokens are signed rather than stored and never expire, so rotating UNSUBSCRIBE_SECRET silently breaks every unsubscribe link already sitting in someone's inbox. Exclude it from routine key rotation: a dead unsubscribe link sends people to the spam button instead.

host binds loopback by default

HOST defaults to 127.0.0.1 and the API passes it straight to server.listen(PORT, HOST). Inside a container that accepts connections from the container itself and nothing else — a published Docker port, a Kubernetes service, or a PaaS router all reach it over a network interface, not loopback.

The failure looks like a success: the process logs that it is listening, stays up, and every request from outside is refused or times out, so a platform healthcheck fails against an apparently healthy container. The Compose file above sets HOST=0.0.0.0 for this reason. Bind loopback only when something on the same host — a sidecar, a local reverse proxy — is the sole client.

gotcha

The API sends Access-Control-Allow-Origin: *. Combined with an exposed port that makes every project-scoped endpoint reachable from any browser origin that has a key. Terminate TLS and keep :3000 on an internal network or behind a proxy you control — do not publish it straight to the internet.

Before you call it done

  • NODE_ENV=production, with DATABASE_URL and REDIS_URL set explicitly.
  • Exec-form CMD, so SIGTERM reaches node.
  • stop_grace_period above 30 seconds on every container that runs delivery.
  • Exactly one deployment with autoMigrate enabled.
  • Redis started with --appendonly yes, on a real volume.
  • Transports registered on every deployment that runs delivery.
  • USER node — the image does not run as root.
  • Port 3000 not published to the public internet.

Operations picks up from here: what to alert on, how to read the metrics endpoint, and how to drain the dead-letter queue.