Ga naar inhoud

Operations

Runbook voor oplevering, go-live-checks, restore en support. Fase-overgang naar het master dashboard: Freeze-check.

Commando Package
bootstrap @platform/ops — site + pages + nav uit JSON
create-user @platform/ops — CMS-gebruiker + eenmalig wachtwoord
backup / restore / verify @platform/ops
provision-db @platform/ops — rol + database tenant_<slug>, DATABASE_URL eenmalig op stderr
provision-tenant-db.sh scripts/ — wrapper om provision-db met positioneel <tenant_id> [password]
backup-media.sh / verify-backup.sh scripts/ — media-cron en dump-probe
scratch-verify-db.sh scripts/ — scratch-database voor de klantvariant hieronder
create-admin @platform/ops — beheerder voor het master dashboard
provision-master-db.sh scripts/ — rol + database platform_master

Elke ops-functie is puur (geen process.env/argv in de library); de CLI is een dunne wrapper. Taak 2.5 kan dezelfde functies aanroepen.

Aparte database, aparte connectiestring: MASTER_DATABASE_URL, niet DATABASE_URL. create-admin weigert een database met een site-tabel, dus een vergissing schrijft geen gebruiker in een klantdatabase.

Terminal window
./scripts/provision-master-db.sh # rol + database platform_master
MASTER_DATABASE_URL='' pnpm --filter @platform/db run migrate:master
MASTER_DATABASE_URL='' pnpm --filter @platform/ops run create-admin -- \
--email --yes # eenmalig wachtwoord

Lokaal is er geen psql op de host en is het compose-image alpine (geen bash), dus draai de twee SQL-blokken uit provision-master-db.sh via de container: docker compose exec -T -e PGPASSWORD=platform postgres psql -U platform -d postgres ….

Nieuwe beheerders en wachtwoordresets lopen ook via create-admin (--list, --reset-password): het dashboard heeft geen gebruikersbeheer-UI en verstuurt geen resetmail. Zet platform_master in de Coolify per-database-backuplijst zodra hij op de VPS staat.

Op de tenant-detailpagina: knop Provisioneren bij status provisioning of failed. Die POSTt /api/tenants/:slug/provision en ververst het detail (activiteit + deployments). Ciphertext blijft uit browser-JSON.

Lokaal zonder Coolify: zet NUXT_POSTGRES_ADMIN_URL (CREATEROLE+CREATEDB of superuser) en laat NUXT_COOLIFY_TOKEN leeg. De run vult de database, migraties en secrets, schrijft tenant.provision_partial, en laat status op provisioning. Met Coolify-config gaat de run door tot start; een startfout zet status failed en tenant.provision_failed.

/migrations (link Migraties in de kop) toont per tenant de schemastand tegen de migraties in het draaiende master-image, met Bijwerken per tenant en Alles bijwerken. Eén run tegelijk; een mislukte tenant stopt de rest niet. Het resultaat per database staat in de run-historie onderaan en als tenant.migrated / tenant.migration_failed op de klantpagina.

Bestaande tenants (vóór 2.5 aangemaakt, zoals pilot en rb-media) hebben geen DSN in de master. Koppel die eenmalig op de klantpagina, sectie Database: plak de DATABASE_URL uit de wachtwoordmanager. De master controleert dat de databasenaam tenant_<slug> is, dat hij verbinding krijgt en dat site.tenant_id dezelfde slug draagt; daarna staat hij versleuteld in database_url_encrypted. Overschrijven kan niet (409): rotatie is een los punt.

Volgorde bij een release met schemawijziging: master deployen → /migrations groen → sites uitrollen. Geblokkeerd · database loopt vóór op dit image betekent dat het master-image ouder is dan het site-image; eerst de master herbouwen. Zie Deploy § Migraties.

  1. Database + rol: PGHOST=… PGUSER=postgres PGPASSWORD=… pnpm --filter @platform/ops run provision-db -- --tenant <id> (of ./scripts/provision-tenant-db.sh <id>). De admin-rol is superuser, of heeft CREATEROLE en CREATEDB met createrole_self_grant = 'set, inherit'. Zonder die instelling stopt Postgres 16 op must be able to SET ROLE. De DATABASE_URL verschijnt eenmalig op stderr, al percent-encoded. Bewaar hem in de wachtwoordmanager. Een tweede run laat het wachtwoord van een bestaande rol staan en meldt dat. Geef dan --password (of TENANT_PASSWORD) mee om dezelfde URL terug te krijgen.
  2. Coolify-app met build/runtime-env per Deploy.
  3. Deploy. Bij een nieuwe tenant slaat Coolify de pre-deployment migratie over (geen draaiende container om in te docker exec) en meldt tóch “finished”. Draai hem één keer met de hand: docker exec <app-container> node /app/scripts/migrate.mjs. Zie Deploy.
  4. bootstrap -- --config ../../tenants/<id>.json --yes (eerst --dry-run). Pad is relatief aan packages/ops; verbinding via de tunnel uit Deploy.
  5. create-user -- --email … --role admin --yes → wachtwoord in de wachtwoordmanager.
  6. DNS: apex én admin. (beide in Coolify domains met https://-prefix). Preview-tenants: de provisioner vult die Coolify-string; de wildcard *.{previewBase} en DNS-01 staan in Deploy (sectie Preview-wildcard). admin.{slug}.{previewBase} valt buiten een één-label-wildcard.
  7. Container healthy; curl -I op beide hosts met geldige certificaten.
  8. /robots.txt toont Sitemap: https://<SITE_DOMAIN>/sitemap.xml.
  9. verify --http https://<domein> --json → alles PASS. Naast DATABASE_URL zelf de vier build-vars meegeven (S3_BUCKET, S3_REGION, S3_PUBLIC_URL, S3_FORCE_PATH_STYLE): die zijn ingebakken en staan dus niet in de runtime-env van de container. Ontbreken ze, dan faalt media.s3.config. Heeft de tenant nog geen media-rijen, dan meldt verify de media-checks als SKIP (skipped: true in de JSON) en blijft de run groen — een SKIP is geen bewijs over R2, dus draai hem na stap 10 nog eens.
  10. Handmatig: inloggen, primary-kleur wijzigen → publieke site verandert, logo uploaden, nieuws publiceren, contactformulier + mail, wachtwoordreset + mail.
  11. /_image?href=… geeft 200 (bewijst ingebakken S3_PUBLIC_URL / remotePatterns).
  12. grep -c build-placeholder over dist/ in de container → 0.

Volledige vernietiging+restore hoort op een pilot-tenant, nooit op de klantdatabase. Bewijs vastleggen als nieuwe pagina onder Taken en beslissingen (besluiten/restore-test-YYYY-MM-DD.md), zoals de rehearsal van 12 augustus 2026.

Slagingscriteria: verify groen inclusief rijtellingen, admin logt in met hetzelfde wachtwoord, logo rendert met content-length == size_bytes, contactformulier mailt, enums bestaan, verse migrate is een no-op. Meet RTO (stopwatch) en RPO (backupinterval).

Na restore: app-container herstarten (connectiecache).

Niets vernietigen: backup nemen, restoren in een scratch-database, verifiëren, droppen. Media wordt alleen gelézen.

Terminal window
# 1. Scratch-database naast de live tenant. Eigenaar is de bestaande tenant-rol,
# dus de live DATABASE_URL werkt met alleen de databasenaam gewijzigd.
./scripts/scratch-verify-db.sh create <id>
# 2. Backup van de live tenant (read-only voor de klant).
DATABASE_URL=<live> pnpm --filter @platform/ops run backup -- --tenant <id> --out ./backups
# 3. Restore in de scratch-DB. --skip-media is hier verplicht.
pnpm --filter @platform/ops run restore -- --from ./backups/<id>/<ts> \
--database-url postgresql://tenant_<id>:…@<host>:5432/tenant_<id>_verify \
--skip-media --yes
# 4. Media apart, uitsluitend via HeadObject. Geen --http: de live site blijft erbuiten.
DATABASE_URL=<scratch> pnpm --filter @platform/ops run verify -- \
--expect ./backups/<id>/<ts>/meta.json
# 5. Opruimen.
./scripts/scratch-verify-db.sh drop <id>

Draai de scripts uit stap 1 en 5 in een postgres:18-alpine-container op het coolify-netwerk; de Coolify-Postgres is niet publiek exposed (zelfde truc als packages/ops/src/lib/docker-pg.ts).

restore weigert een *_verify-database zonder --skip-media. De manifest-keys zijn de live keys, dus een media-restore zou de klantobjecten in de bucket overschrijven — geen vlag maakt dat goed.

verify --expect is ook op een drukke site exact: backup leest de rijtellingen uit dezelfde snapshot als pg_dump. Een counts.match-melding is dus echt en geen timingartefact.

  • Eén admin voor de klant, één admin voor support, editors voor de rest.
  • Wachtwoorden out-of-band via wachtwoordmanager-share, nooit per mail.
  • De app dwingt geen wachtwoordwijziging bij eerste login af; er is nog geen gebruikersbeheer-UI — wijzigingen via create-user / --reset-password.

EMAIL_FROM op een Resend-geverifieerd domein. mail.ts faalt stil bij ontbrekende config — bewijs vóór overdracht met één echte inzending en één echte resetmail.

form_submissions bevat persoonsgegevens; backups dus ook.

Dataset Bewaartermijn Toelichting
Postgres-backups (Coolify → platform-backups) 35 dagen lifecycle-regel op de bucket
Media-replica (B2, platform-media-backup) géén leeftijdsregel levende spiegel van R2; een verwijderregel zou live media wissen
Media-archive (B2, platform-media-archive) 35 dagen, per incident beoordelen --backup-dir per datum; regel tijdelijk uit als er een incident loopt
Operator-backups (./backups/…) lokaal/offsite max 35 dagen, tenzij een incident loopt geen PII langer bewaren dan nodig
form_submissions in de live DB door klant te bepalen; default: handmatig opschonen tot er CMS-UI is

Leg een klantspecifieke termijn vast in de overeenkomst vóór de eerste echte inzending.

pnpm --filter @platform/db run seed is destructief en geblokkeerd zonder ALLOW_DESTRUCTIVE_SEED=1, en weigert niet-demo databases. Niet gebruiken op klantomgevingen.