Deployment guide
Vigil deploys as two processes and one database: the Next.js app, the worker, and PostgreSQL 18+. Nothing else is required.
Two more containers exist and neither is part of that install. Deploy the synthetics runner only if you run browser journeys (section 4 below), and remote probe agents only if you want checks executed from machines other than the one Vigil runs on (remote probes). Every other feature in the product (maintenance windows, alert routing, objectives, runbooks, operations tasks) is the same two processes doing more work on the same queue, and needs no deployment change at all.
1. Docker Compose (single host)
The included docker-compose.yml is production-shaped:
Postgres with a persistent volume, a one-shot
migrate service, the standalone app image, and the
worker image.
# .env next to docker-compose.yml
BETTER_AUTH_SECRET=<openssl rand -base64 32>
APP_URL=https://vigil.yourdomain.com
POSTGRES_PASSWORD=<strong password>
docker compose up --build -d
Put a TLS-terminating reverse proxy in front of port 3000 (Caddy shown; nginx/Traefik equivalent):
vigil.yourdomain.com {
reverse_proxy localhost:3000
}
Upgrading a running stack
git pull
docker compose build
docker compose up -d # migrate runs first, then app/worker restart
2. Managed platforms
- App (Next.js): Can be deployed to Vercel (Serverless) or any container platform (Render, Fly.io, Railway). If deploying to Vercel, simply connect your repository and configure the environment variables.
-
Worker (Background checks): Because Vercel is
serverless, it does not support long-running processes required
for queue polling. You must run the worker process on a
container/server platform. Fly.io (free tier
supports small persistent VMs) or paid background workers on
Render (starts at $7/mo) or
Railway are ideal. Configure it to build using
npm ciand start usingnpm run worker(or target theworkerstage in the Dockerfile). -
Database: Any managed PostgreSQL
18+ database (required for native
uuidv7()). Neon.tech is highly recommended and offers a free tier. -
Migrations: Run
npx drizzle-kit migrateas a pre-deploy or release step, or run it locally pointing to your production database before launching.
Hybrid Setup Example (Vercel + Neon + Fly.io/Render Worker)
- Database: Set up a database on Neon, copy the connection string.
-
Frontend (Vercel): Connect your GitHub repo,
set the Framework Preset to Next.js. Add
DATABASE_URL(Neon),BETTER_AUTH_SECRET(generate one), andAPP_URL(your deployment URL) to the Environment Variables. -
Worker: Create a new background worker/VM on
Fly.io (free) or Render (paid). Link the same repository. Add
the same
DATABASE_URL,BETTER_AUTH_SECRETandAPP_URLenvironment variables (the worker embedsAPP_URLin incident notification links). Set the Start Command tonpm run worker. -
Communication: Both processes will securely
coordinate through the Neon database using
pg-boss. No Redis or open network ports between the frontend and worker are needed.
3. Bare metal / VM without Docker
npm ci
npm run build
npm run db:migrate
# standalone output does not include static assets, copy them in once per build:
cp -r .next/static .next/standalone/.next/static
cp -r public .next/standalone/public
# process manager of your choice (systemd/pm2):
node .next/standalone/server.js # app, PORT=3000
npm run worker # worker
Running more than one worker is supported and needs no
configuration: Postgres arbitrates who fires the cron, who runs
the scheduler tick and who takes each check, so a second replica
is capacity rather than a coordination problem. Give each one a
distinct VIGIL_ACTOR_NAME (its ledger identity;
sharing one means sharing a chain and contending on its sequence).
MONITOR_SCHEDULER_BATCH bounds how many due monitors
one tick enqueues; the default of 5000 suits fleets far larger
than most installations run.
4. The two optional planes
Neither is in docker compose up. Both are commercial,
and both are removed from Vigil Core by name.
The synthetics runner, for
synthetic-browser journeys only. It is a Compose
overlay, and the worker and the runner must present the same
token:
echo "SYNTHETICS_RUNNER_TOKEN=$(openssl rand -base64 32)" >> .env
docker compose -f docker-compose.yml -f docker-compose.synthetics.yml up -d
The overlay puts the runner on a network of its own and publishes no port, which is the point: what is reachable is a browser that renders whatever it is told to. Vigil refuses the unsafe deployments rather than warning about them: beyond loopback a token is required, and on a public address https as well. It checks that before it builds a request body, so a journey's secrets are never put on a connection that would have been refused. API journeys need none of this: they execute in the worker through the same egress guard every other HTTP check uses.
Remote probe agents, for checks executed somewhere other than the host Vigil runs on. A probe is a headless container that connects outbound only, so there is no inbound port and nothing to open in a firewall. Enrollment, rotation, quorum, firewall rules and mixed-version fleets are in remote probes and quorum. Remote rounds need an interval of 20 seconds or more.
Environment
See sample.env.production for the full annotated
template. Required: DATABASE_URL,
BETTER_AUTH_SECRET, APP_URL.
Resource envelope
Measured on the 1.26.0 release over hour-long soaks, at idle and with fleets of 100, 500 and 1,000 monitors on one worker:
- App (Next.js): roughly 115 MB RSS, flat under load.
- Worker: roughly 180 MB as a process tree at idle, 200 MB at 100 monitors, 280 MB at 500 to 1,000. Post-settle memory slopes were negative on every plane over hour-long windows: no leak.
- PostgreSQL: 140 to 210 MB of private memory plus shared buffers and your data. Job-queue retention is bounded (finished queue rows are deleted after an hour, swept every ten minutes), so the queue does not grow the database at steady state.
2 GB of RAM fits a single-host Compose install of all services plus PostgreSQL with headroom. Disk is dominated by check history, bounded by the 90-day retention job. A browser journey adds its own Chromium on top, in the runner's container rather than in the worker.
Operational notes
-
Health:
GET /api/healthreturns 200 when the app can reach Postgres and 503 otherwise, point load-balancer/orchestrator probes at it (the compose file already does). The worker logsworker startedon boot and exits non-zero on fatal errors, wire it into your restart policy. -
Logs: both processes emit structured JSON
(pino) on stdout. Set
LOG_LEVEL=debugtemporarily for check-level detail. -
Backups: one
pg_dumpcovers everything, see Backups & restore below for the procedure. -
Email: set
RESEND_API_KEY(andEMAIL_FROM, a Resend-verified sender) on both the app and the worker to deliver incident emails via Resend; without a key they fall back to structured logs. A future SMTP/SES provider is anotherEmailTransportinsrc/modules/notifications. -
Webhooks: configured per organization under
Settings → Notifications, no env needed. Receivers
verify the
X-Vigil-Signatureheader (HMAC-SHA-256 of the raw body). See ARCHITECTURE.md §8 for the payload and event list. -
On-call & escalation: schedules and
escalation policies are configured under
Settings → Escalation; attach a policy to a monitor on
its form. Email steps need no extra config. SMS and voice steps
deliver through Twilio, set
TWILIO_ACCOUNT_SID,TWILIO_AUTH_TOKENandTWILIO_FROM_NUMBERon the worker (it runs escalation); without them those steps are a logged no-op and the ladder still runs. Members set their own escalation phone (E.164) under Settings → General → Your profile. -
Status-page subscriptions: visitors to a
public status page can subscribe by email (double
opt-in) to incident open/update/resolve notifications. Delivery
reuses the email transport above, no separate config; without
RESEND_API_KEYthe confirmation and notification emails are written to the logs like every other email. Operators see subscriber counts under Settings → Status page. -
Automatic recovery: configured per monitor on
its detail page. Point the recovery endpoint at something inside
your infrastructure that fixes the failure, a restart hook, a
runbook trigger; a dependency-free starter lives in
examples/recovery-receiver.mjs(verify the signature, run one command), with ready-made Docker, Compose, Kubernetes and systemd templates inexamples/recovery-templates.md. The worker verifies the failure before triggering and verifies the target again afterwards; attempts are bounded per incident, capped per day (restart-loop guard), and recorded on the incident timeline. With hold alerts while recovering enabled, a verified recovery pages nobody; a failed or overdue one pages immediately. - Planned work, routing, objectives, runbooks and tasks need no deployment configuration. They are configured in the product and run on the worker's existing queues.
- Scaling: app and worker are independently horizontal; the queue serializes per-monitor work, and there is no leader election to operate. See ARCHITECTURE.md §9 for the pressure → response table.
Backups & restore
All state lives in Postgres: domain data, auth, the audit trail,
status-page subscribers and the job queue. The
pgboss schema is disposable (it rebuilds on worker
start), so a single dump is a complete backup. The only other
thing to keep is your .env,
the same BETTER_AUTH_SECRET must survive a
restore, because sessions and status-page subscription tokens are signed
with it; restoring data under a new secret signs everyone out and
invalidates pending confirm/unsubscribe links.
Back up (compose deployment; adjust user/db if you changed them):
docker compose exec postgres pg_dump -U vigil -Fc vigil > vigil-$(date +%F).dump
Run it from cron at whatever cadence your incident history is worth. Daily is typical. Keep dumps off the host that runs Vigil.
Restore onto a fresh stack:
docker compose up -d postgres # just the database
docker compose exec -T postgres pg_restore -U vigil -d vigil \
--clean --if-exists --no-owner < vigil-YYYY-MM-DD.dump
docker compose up -d # migrate no-ops, app + worker start
The migrate service compares the restored journal against the shipped migrations, so restoring an older dump into a newer checkout applies the missing migrations automatically.
Disaster recovery is those two steps on a new
host: copy the source checkout and your .env, restore
the latest dump, docker compose up -d. Nothing else
holds state, no volumes to move besides Postgres, no local files
the app writes.
Troubleshooting
Symptom → cause → fix, from real deployments:
-
App container exits immediately, logs show
getaddrinfo/EAI_AGAIN. Next's standalone server binds to$HOSTNAME, which Docker sets to the container id. The shipped image already pinsHOSTNAME=0.0.0.0; if you run the standalone build outside this image, set that yourself. -
relation "..." does not exist, migrations haven't run. Compose runs them via the one-shotmigrateservice; on bare metal runnpm run db:migratebefore starting. -
No emails arrive: without
RESEND_API_KEYevery email is written to the logs instead (grep foremail); that's the designed fallback, not a failure. With a key set:EMAIL_FROMmust be a Resend-verified sender, the defaultonboarding@resend.devonly delivers to the Resend account owner. -
Monitor against an internal host fails instantly. Monitor URLs are SSRF-guarded: private and loopback addresses
are refused by default.
ALLOW_PRIVATE_MONITOR_TARGETS=truelifts this for dev only. Recovery endpoints are the deliberate exception (your own restart hooks are usually internal), see docs/security. -
Sign-up disabled / every mutation rejected.
DEMO_MODE=trueis set. That's the read-only public-demo switch (docs/DEMO.md), not a broken install. -
Browser sign-in rejected while
curlworks.APP_URLmust equal the origin users type into the browser (it's the auth trusted origin). A port or scheme mismatch fails exactly this way. -
SMS/voice escalation steps never fire: the
three
TWILIO_*variables must be set on the worker. Without them those steps are a logged no-op by design; email steps and the rest of the ladder still run. -
A recovery attempt shows
runningforever. The worker was interrupted mid-attempt (recovery jobs deliberately never retry). The nightly retention job closes attempts stuck over an hour as failed; no action needed beyond restarting the worker. - Status page shows slightly stale data: public pages are cached for ~60 s so an outage traffic spike never reaches Postgres. That lag is by design.