Skip to content

Deployment, migrations & CI

Configuration

All configuration is done through environment variables. See .env.example for the full list.

Variable Required Purpose
DATABASE_URL yes PostgreSQL connection string
SESSION_SECRET yes Signing key for session cookies and key for the address counters of the form rate limit (openssl rand -hex 32). Without it the web app does not start; below 32 characters it logs a warning. The worker does not need it.
SETTINGS_ENCRYPTION_KEY yes for secrets 64 hex characters, encrypts SMTP, Resend, Twilio, webhook and VAPID secrets
SETTINGS_ENCRYPTION_KEY_OLD only when rotating Previous key for pnpm db:rotate-secret-key; remove it afterwards
ADMIN_PASSWORD_HASH optional Bootstrap admin, only until a database admin exists
ADMIN_EMAIL optional E-mail of the bootstrap admin, default admin@status.local
ADMIN_BOOTSTRAP_DISABLED_AFTER optional ISO date after which the bootstrap login is refused
APP_URL recommended Public base URL for links in e-mails, double opt-in and the RSS feed (/feed.xml). The worker needs it as well.
NEXT_PUBLIC_STATUS_PAGE_URL optional Takes precedence over APP_URL for links. Order: NEXT_PUBLIC_STATUS_PAGE_URL, APP_URL, http://localhost:<PORT>.
NEXT_PUBLIC_STATUS_PAGE_NAME optional Fallback name while no site name is stored in the settings, and default for db:seed. The displayed name comes from the settings.
TRUSTED_PROXY_IP_HEADER optional Where the client IP comes from: auto (default), x-real-ip or x-forwarded-for
TRUSTED_PROXY_COUNT optional Number of trusted proxies in x-forwarded-for mode, default 1
AUTH_RATELIMIT_* optional Login and API-token throttling. AUTH_RATELIMIT_ENABLED=false disables it.
PUBLIC_FORM_RATELIMIT_* optional Throttling of the public forms: _ENABLED, plus _IP_, _EMAIL_ and _UNKNOWN_IP_, each with MAX, WINDOW_MIN and LOCK_MIN. Defaults 10/60/60, 1/10/10 and 100/60/60.
STATUS_PAGE_EMAIL_DRIVER optional capture or log records mails instead of sending them and always reports success (tests)
STATUS_PAGE_SMS_DRIVER optional capture records SMS instead of sending them (tests)
LOG_LEVEL, LOG_FORMAT optional Logger threshold (debug–error) and output (json or pretty)
NEXT_ALLOWED_DEV_ORIGINS development only Additional hosts for the dev server, comma-separated

Mail, SMS, webhook and push providers are configured in the admin UI, not in environment variables. The exception is SETTINGS_ENCRYPTION_KEY, which must be set in the environment.

Warning

.env.example sets STATUS_PAGE_EMAIL_DRIVER=capture. Remove that line if you use the file as a template for production. Otherwise every dispatch reports success without any mail going out.

Danger

The encryption key is not stored in the database. If it is lost, encrypted secrets cannot be read and must be entered again. Back it up together with the database dump.

Encrypting secrets and rotating the key

  • If SETTINGS_ENCRYPTION_KEY was only set later, pnpm db:encrypt-secrets encrypts the secrets that were stored as plaintext so far, once.
  • To rotate, set the previous key as SETTINGS_ENCRYPTION_KEY_OLD and the new one as SETTINGS_ENCRYPTION_KEY, run pnpm db:rotate-secret-key, then remove SETTINGS_ENCRYPTION_KEY_OLD.
  • Against a non-local database, both scripts only run with --yes-i-am-sure.

Docker

File Purpose
Dockerfile Web app image. Runs next start -p 3100. Contains a HEALTHCHECK on /api/health.
Dockerfile.worker Worker image. Same code base, no build step, no port. Run it as a second application.
docker-compose.yml Local PostgreSQL 16 (port 5433) and an optional E2E application container
docker build -t slatus .
docker run -p 3100:3100 --env-file .env slatus

Info

The image listens on port 3100. Map this port, not 3000. If SESSION_SECRET is missing, the container does not start and the HEALTHCHECK fails.

Deploy the web app and the worker from the same repository, with identical DATABASE_URL and SETTINGS_ENCRYPTION_KEY. Run one worker instance.

Reverse proxy

The rate limits count per client IP. Run SLAtus behind a trusted reverse proxy with HTTPS that forwards the client IP, and set TRUSTED_PROXY_IP_HEADER to match:

  • Proxy sets X-Real-IP (for example nginx with proxy_set_header X-Real-IP $remote_addr;): TRUSTED_PROXY_IP_HEADER=x-real-ip.
  • Proxy only appends to X-Forwarded-For: TRUSTED_PROXY_IP_HEADER=x-forwarded-for, and TRUSTED_PROXY_COUNT set to the number of proxies in front of the app.
  • auto reads x-real-ip, otherwise the last x-forwarded-for entry. Behind a pure X-Forwarded-For proxy, a client can then inject its own X-Real-IP header.
  • Without a proxy, Next.js only fills X-Forwarded-For if the header is missing. A client can then fake its IP.
  • If the proxy sets no IP header, all visitors share the proxy's IP. In production, the app logs this once as a warning.

Details on the detection are in Auth & security.

Migrations

  • Migrations are forward-only. Each migration is an SQL file in drizzle/, generated with pnpm db:generate from lib/db/schema.ts.
  • They are applied only with pnpm db:migrate (scripts/migrate.ts). Each migration runs in its own transaction, because PostgreSQL can use a new enum value only after commit.
  • Rollbacks are documented, not automated. See docs/context/migrations.md in the repository.

Typical deployment order: stop the worker and web app (or deploy with a short pause), run pnpm db:migrate, start the new version. The worker creates missing template rows for new event types when it starts.

Local development

cp .env.example .env          # set SESSION_SECRET, SETTINGS_ENCRYPTION_KEY, ADMIN_PASSWORD_HASH
pnpm install
pnpm db:up                    # PostgreSQL in Docker on port 5433
pnpm db:migrate
pnpm db:seed                  # demo data; dev admin: admin@status.local / admin123
pnpm dev                      # http://localhost:3000
pnpm worker                   # second terminal, required for notifications and checks

The seed scripts delete data. They refuse to run against anything other than a local host or a database whose name ends in _dev, _e2e or _test, unless --yes-i-am-sure is passed. All db:* scripts read .env.

With Next.js 16, pnpm dev writes to .next/dev and pnpm build to .next, so both can run in parallel. A second next dev in the same directory is rejected. The E2E tests use a separate directory .next-e2e through NEXT_DIST_DIR.

Quality gates

Command What it checks
pnpm typecheck TypeScript (tsc --noEmit)
pnpm lint ESLint
pnpm test Unit tests (Vitest)
pnpm test:integration Integration tests against a real database
pnpm build Production build
./scripts/agent-verify.sh Typecheck, lint, unit tests, db:migrate (only if the database is reachable on localhost:5433) and build. No integration tests. E2E only with RUN_E2E=1.
pnpm test:e2e Playwright end-to-end tests with a dedicated statuspage_e2e database. Two projects: main with all specs, then i18n-de for the German UI. The default is next dev; the e2e-app container is only used with E2E_USE_DOCKER=1.

Continuous integration

.github/workflows/ci.yml runs on pushes to main, on release-* tags and on pull requests. A new push cancels the running job of the same branch. Documentation-only changes (docs/**, .claude/**, **/*.md) do not trigger CI.

The workflow starts a PostgreSQL 16 service and runs, in order:

  1. pnpm audit --audit-level high (blocking). Known exceptions are listed in pnpm-workspace.yaml under auditConfig.ignoreGhsas, security overrides under overrides.
  2. pnpm db:migrate and pnpm db:seed
  3. ./scripts/agent-verify.sh
  4. pnpm test:integration (blocking)
  5. the Playwright suite

Mirror: .github/workflows/mirror.yml publishes the deployable application code, without the agent setup and documentation, to the deploy repository Mach-IIDeV/slatus-web on release-* tags. The files to mirror are listed in scripts/app-files.txt. The workflow can also be started manually with any commit, tag or branch.