Skip to main content

Deployment and Operations

Vuoro runs as a Docker Compose stack (API, web, docs, Postgres, Redis, backup sidecar) behind a Caddy reverse proxy on a single host in Helsinki, Finland. Primary data and off-site backups both stay in Finland.

CI Pipeline​

Every push to main runs the GitHub Actions pipeline on a self-hosted runner:

  1. Install with Node 22 and pnpm 11 (frozen lockfile).
  2. Lint (including the i18n key-parity check), typecheck, and security gates. The hard gate fails only on vulnerabilities in the production dependency closure. Advisory scanning, secret scanning and SAST run alongside it.
  3. Tests: API unit tests, API E2E against a real Postgres, and web unit tests, followed by Playwright web E2E.
  4. Docker build: the API, web and docs images are built once in CI, using the production NEXT_PUBLIC_* build arguments, and tagged vuoron-<service>:<short-sha>. The docs site build is also verified.
  5. Deploy (below), followed by a notification.

Hotfix fast path. If the commit subject contains [hotfix], the unit tests and E2E stages are skipped. Lint, typecheck, security gates and the image build still run. Use it only for small fixes that have already been validated locally.

Work lands on main directly.

Deploys​

The production host never builds images. The deploy job:

  1. Verifies that the CI-built images for the commit exist.
  2. Ships them to the host with docker save | gzip | ssh docker load, before touching the running stack.
  3. Tags the current images for rollback and takes a short-lived pre-deploy database snapshot. The deploy aborts if the snapshot fails.
  4. Runs database migrations and RLS policies on the host, before any new containers start.
  5. Retags the shipped images as :latest and runs docker compose up -d --no-build.
  6. Waits for GET /api/health/ready, then checks that version.sha from GET /api/health matches the shipped commit. If either check fails, the previous images are restored automatically. Rollback swaps images and does not run compose down.

A deploy takes about a minute. Building on the host was retired because the Next.js build could exhaust the production host's memory. There is no container registry: images travel directly from the runner to the host.

Backups and Disaster Recovery​

WhatHow
PostgreSQLDaily dump from the backup sidecar, kept locally and pushed off-site with restic (encrypted) to a Hetzner Storage Box. 30 daily snapshots are retained.
.env secretsCaptured daily into the same restic repository under a separate config tag, as a safety net alongside the password-manager vault entry.
Code and imagesCode lives in git. Images can be rebuilt from git during a recovery.
RedisNot backed up. Queue state is rebuilt from Postgres by the schedulers.

Monitoring:

  • GET /api/health/backup returns 503 if no backup has succeeded in the last 26 hours. Point external uptime monitoring at it.
  • A weekly backup recovery test workflow restores the latest local dump, the off-site dump and the off-site config snapshot into a throwaway Postgres instance, and verifies them. It never touches the live database.

The complete restore procedure is in the repository's docs/disaster-recovery.md runbook. It covers provisioning a fresh host in the same region, restoring secrets and the database, applying RLS and migrations, smoke testing and cutting DNS over, along with a quarterly drill checklist. The targets are RTO 4 hours and RPO 24 hours.

Configuration​

The API validates its environment at boot, and a missing required variable stops startup. Required variables are DATABASE_URL (non-superuser application role), DATABASE_ADMIN_URL (admin role for migrations and the few cross-tenant paths), JWT_SECRET and JWT_REFRESH_SECRET. Optional integrations are only switched on when their variables are set:

FeatureVariables
BI outbound feedBI_API_TOKEN, BI_API_TENANT_ID
BI demand ingestBI_INGEST_GCS_BUCKET, GOOGLE_APPLICATION_CREDENTIALS, BI_INGEST_TENANT_ID
AI roster draftingROSTER_DRAFT_ENABLED, ROSTER_DRAFT_LLM_API_KEY, ROSTER_DRAFT_LLM_MODEL
Public usage statsPUBLIC_STATS_TOKEN
EmailBREVO_API_KEY (plus EMAIL_OVERRIDE_RECIPIENT for non-production)
Error trackingSENTRY_DSN
Refresh-token grace windowREFRESH_ROTATION_GRACE_SECONDS (default 60; 0 means strict rotation)
Backup freshnessBACKUP_FRESHNESS_PATH

Optional variables that Compose passes through as empty strings must accept '' in the validation schema. Otherwise an unset optional variable prevents the API from booting.

Background Jobs​

Scheduled work runs as BullMQ repeatable jobs in the API process. Jobs that touch tenant data run once per tenant within that tenant's RLS context. Examples:

  • Daily BI demand ingest (06:00 Helsinki)
  • Shift-offer expiry reaper
  • Monthly TES experience accrual with a rolling re-evaluation window
  • Scheduled payroll reconciliation sweeps that alert on differences between source data and stored payroll results
  • Certification expiry reminders
  • Hourly open clock-in warnings (07–20 Helsinki). Supervisors are notified once about any clock-in left open for more than 14 hours.