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.
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:
| Container | What it is | Required |
|---|---|---|
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 |
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"]
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
/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:
| Deployment | autoMigrate | Why |
|---|---|---|
| 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();
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:
| Variable | Value | Why |
|---|---|---|
HOST | 0.0.0.0 | The one that bites. The default binds loopback and the platform router cannot reach it — see the gotcha below |
NODE_ENV | production | Below 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 |
PORT | — | Leave it unset. Railway injects it and the config schema reads it; overriding it breaks the healthcheck |
PUBLIC_URL | https://${{ RAILWAY_PUBLIC_DOMAIN }} | Where an inbox reaches you. Needed for unsubscribe links and provider webhooks |
UNSUBSCRIBE_SECRET | 16+ random characters | Set once and never rotate — see the warning below |
ADMIN_API_KEY | your bootstrap key | Only 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.
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
| Variable | Default | Notes |
|---|---|---|
NODE_ENV | development | Must be production, or NotifKit tries to start testcontainers |
DATABASE_URL | — | Required in production |
REDIS_URL | — | Required in production |
PORT | 3000 | Only meaningful where services includes api |
HOST | 127.0.0.1 | Bind address. The default accepts loopback only — set 0.0.0.0 in any container whose port is reached from outside it. See below |
LOG_LEVEL | info | fatal → trace, or silent |
ADMIN_API_KEY | — | Enables POST /v1/projects. Leave unset on deployments that do not manage projects |
PUBLIC_URL | — | Where an inbox can reach this API, e.g. https://notify.example.com. Not HOST/PORT, which are the bind address behind your proxy |
UNSUBSCRIBE_SECRET | — | Signs unsubscribe tokens, minimum 16 characters. Effectively permanent — see below |
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 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.
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, withDATABASE_URLandREDIS_URLset explicitly.- Exec-form
CMD, soSIGTERMreaches node. stop_grace_periodabove 30 seconds on every container that runsdelivery.- Exactly one deployment with
autoMigrateenabled. - 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.