Deploy van een klantsite
Handmatige Coolify-deploy van site-template (fase 1). Provisioning vanaf het
master dashboard (taak 2.5/2.6) maakt de app, zet env en schrijft de
preview-domeinen; DNS-01/wildcard-TLS blijft een operatorstap.
Build-context = repo-root. Dockerfile: apps/site-template/Dockerfile.
docker build -f apps/site-template/Dockerfile \ --build-arg S3_PUBLIC_URL=https://media.example.com \ --build-arg S3_BUCKET=platform-media \ --build-arg S3_REGION=auto \ --build-arg S3_FORCE_PATH_STYLE=false \ --build-arg EMAIL_FROM=noreply@example.com \ --build-arg SOURCE_COMMIT="$(git rev-parse HEAD)" \ -t site-template .Op een Mac voor een linux/amd64-VPS: docker buildx build --platform linux/amd64 ….
Build vs runtime env
Section titled “Build vs runtime env”Dit komt overeen met turbo.json build.env (public, ingebakken) versus passThroughEnv (secrets, runtime).
| Variabele | Wanneer | Waarde |
|---|---|---|
TENANT_ID |
runtime | moet matchen met de site-rij én de media-prefix |
DATABASE_URL |
runtime | postgresql://tenant_<id>:…@<pg-host>:5432/tenant_<id> |
SITE_DOMAIN |
runtime | apex, geen scheme, geen www. (bijv. klant.nl) |
BETTER_AUTH_SECRET |
runtime | openssl rand -base64 32, uniek per tenant |
PLATFORM_API_KEY |
runtime | openssl rand -base64 32, uniek per tenant. Header X-Platform-Key |
PLATFORM_WEBHOOK_SECRET |
runtime | openssl rand -base64 32, HMAC van uitgaande webhooks |
PLATFORM_WEBHOOK_URL |
runtime | optioneel. Leeg = geen webhooks |
RESEND_API_KEY |
runtime | gedeelde platformsleutel |
S3_ENDPOINT, S3_ACCESS_KEY_ID, S3_SECRET_ACCESS_KEY |
runtime | R2-token beperkt tot de media-bucket |
EMAIL_FROM |
build | Resend-geverifieerd afzenderadres (noreply@mail.okhema.studio) |
S3_REGION / S3_BUCKET / S3_PUBLIC_URL / S3_FORCE_PATH_STYLE |
build | platformconstanten |
Wijzigen van een build-variabele vereist een rebuild + redeploy van elke tenant, geen restart. Dat is het bestaande astro:env access: "public"-ontwerp: alle tenants delen één bucket met ${TENANT_ID}/-prefix, dus de media-origin is een platformconstante.
SITE_DOMAIN voedt publicOrigin(), adminOrigin(), Better Auth baseURL/trustedOrigins (alleen de admin-origin) en canonieke URL’s. Domeinwijziging = env wijzigen + container herstarten (sessies op het oude domein worden ongeldig).
Verzenddomein
Section titled “Verzenddomein”EMAIL_FROM staat op het subdomein mail.okhema.studio, niet op de apex. Er mag
maar één SPF-record per domein bestaan; met Resend op de apex zou een latere
mailbox op okhema.studio (Workspace of vergelijkbaar) een handmatige
SPF-samenvoeging vereisen. Op een subdomein staan de twee los.
Eén verzenddomein voor alle tenants, want EMAIL_FROM is een build-variabele:
een ander afzenderadres kost een rebuild + redeploy van iedere tenant. Als
klanten ooit vanaf hun eigen domein moeten verzenden, wordt dit een runtime-var
en per tenant een eigen Resend-verificatie — dat is een ontwerpwijziging, geen
env-aanpassing.
Records in de DNS van okhema.studio (waarden uit het Resend-dashboard):
TXT resend._domainkey.mail, TXT send.mail, MX send.mail met prio 10. De MX
moet naar dezelfde AWS-regio wijzen als Resend toont, anders blijft de
verificatie op region-mismatch staan.
Coolify
Section titled “Coolify”- Postgres 18-resource, niet publiek exposed. Zet
max_connections=200(elke app-container opent tot 10 connecties; zie openstaande punten in Architectuur). Lokaal:docker-compose.ymlgebruiktpostgres:18-alpine— houd die major gelijk aan Coolify. - Tenant-database + rol via
scripts/provision-tenant-db.sh. - Applicatie: build pack Dockerfile, base directory
/, Dockerfile/apps/site-template/Dockerfile, poort4321. - Healthcheck-pad:
/robots.txt(geen DB-call; zie hieronder). - Pre-deployment command:
node /app/scripts/migrate.mjs— breekt de deploy af bij exit ≠ 0. - Env-variabelen per de tabel hierboven. Zet de vijf public vars als Coolify Build Variable.
Domeinen en TLS
Section titled “Domeinen en TLS”Beide hostnames moeten TLS termineren op dezelfde container:
- DNS A-records:
klant.nl,www.klant.nl,admin.klant.nl→ server-IP. - Coolify
domains:https://klant.nl,https://www.klant.nl,https://admin.klant.nl. - Non-www redirect aan (apex is canoniek;
lib/hosts.tsstript een leidendewww.). - Cloudflare: oranje-gewolkte records laten Traefik’s HTTP-01-challenge falen. Grijs-wolken tot de certificaten er zijn, daarna proxy met SSL-modus Full (strict).
Wanneer de proxy aan mag
Section titled “Wanneer de proxy aan mag”Het gratis Universal SSL van Cloudflare dekt maar twee niveaus: okhema.studio
en *.okhema.studio. Eén niveau dieper valt erbuiten.
- Klantdomeinen: proxy mag aan.
klant.nlenadmin.klant.nlblijven binnen die twee niveaus. Zet SSL-modus op Full (strict). - De pilot: proxy blijft uit.
admin.pilot.okhema.studiois een niveau te diep, dus daar heeft Cloudflare geen geldig certificaat voor. Oranje wolken breekt die host, ook nadat Traefik zijn certificaat heeft. Alleen Advanced Certificate Manager (betaald) lost dat op, en dat is de pilot niet waard.
Kort: is admin. het derde niveau van het domein, dan grijs laten en TLS bij
Traefik houden.
Preview-wildcard (taak 2.6)
Section titled “Preview-wildcard (taak 2.6)”De provisioner wijst per tenant een preview-hostname toe:
- Publiek:
{slug}.{previewBase}(defaultpreviewBase=okhema.studio). - CMS:
admin.{slug}.{previewBase}— dezelfde regel alsadminHost()zonder poort. - Coolify
domains:https://{hostname},https://{adminHostname}. Dat veld zet de provisioner bij create, en bij een retry viaPATCH /api/v1/applications/{uuid}(2.5-apps hadden nog geen domains). SITE_DOMAINis de hostname zonder scheme. De Coolify-string wordt niet opgeslagen; alleen dedomains-rij (type: preview) staat in de master.
Leeg NUXT_PREVIEW_BASE_HOST betekent okhema.studio. Zet hem op
preview.okhema.studio als je preview onder een extra label wilt
({slug}.preview.okhema.studio).
DNS (eenmalig per preview-base):
- Wildcard A-record
*.{previewBase}→ het server-IP (dus*.okhema.studioof*.preview.okhema.studio). - Traefik op DNS-01 (niet HTTP-01) voor het wildcard-certificaat
*.{previewBase}. In Coolify: Proxy / Let’s Encrypt met een Cloudflare API-token dat DNS-records op die zone mag wijzigen. - Cloudflare-proxy (oranje wolk) uit laten tot het wildcard-cert er is; daarna alleen aanzetten als Universal SSL het label dekt (zie hierboven).
Admin-host-gat. Een één-label-wildcard *.{previewBase} dekt
{slug}.{previewBase}, niet admin.{slug}.{previewBase}. Dat is één
label dieper. Voor de CMS-host heb je dus een apart certificaat (HTTP-01
per hostname, of een extra record + challenge), of je accepteert dat
preview-CMS via HTTP-01/Traefik per tenant loopt. Dit is hetzelfde gat
als admin.pilot.okhema.studio.
DNS-01 is hier niet op deze VM hands-on geverifieerd; de stappen hierboven zijn het operator-recept, geen bewijs.
Custom domains (Cloudflare for SaaS, taak 2.7)
Section titled “Custom domains (Cloudflare for SaaS, taak 2.7)”De operator registreert één klant-hostname in het master dashboard. Dat is
niet dezelfde hostname als SITE_DOMAIN: de publieke site accepteert elke
niet-admin Host, het CMS blijft op admin.{previewHost} tot een latere
cutover. Registreer niet admin.{klantdomein} in 2.7.
Env op het master dashboard (niet op de klantsite):
| Variabele | Rol |
|---|---|
NUXT_CF_API_TOKEN |
Custom Hostnames-recht op de SaaS-zone. Leeg = Cloudflare uit; de rij wordt wél opgeslagen. |
NUXT_CF_ZONE_ID |
Zone van de fallback-origin (verplicht zodra de token gezet is). |
NUXT_CF_CNAME_TARGET |
Waar de klant naartoe CNAME’t, bv. customers.okhema.studio. Niet afgeleid van de preview-base. |
Eenmalig per platform:
- Cloudflare for SaaS aanzetten op de zone die de fallback-origin host.
- Fallback-origin (bijv.
customers.okhema.studio) naar de Coolify-VPS; TLS daar (of een Cloudflare-cert op die origin). - Custom Hostname-API:
POST /zones/{zoneId}/custom_hostnamesmetssl.method=http,ssl.type=dv. HTTP-DCV start zodra de CNAME live is. - SSL-modus op de klant-hostname: Full (strict) zodra de origin een
geldig cert heeft.
custom_origin_snialleen als de origin een ander cert-CN nodig heeft.
Per klant:
- In het dashboard: hostname toevoegen — meestal
www.klant.nl. - DNS bij de klant:
CNAME {hostname} → {NUXT_CF_CNAME_TARGET}. Apex (klant.nl) vereist CNAME-flattening (Cloudflare DNS, of een ALIAS); anderswww.registreren en apex redirecten. - Optionele TXT
_cf-custom-hostname.…versnelt ownership-prevalidatie; HTTP-DCV heeft die niet hard nodig. - Controleer status tot “Actief (edge-TLS)”. Dat is het Cloudflare- certificaat, niet canonieke cutover.
- Coolify krijgt
https://{custom}erbij (volledige replace vandomains). Traefik mag ACME voor die host proberen; als DNS naar Cloudflare wijst is dat luidruchtig maar onschuldig.
Verificatie van de Cloudflare-API zelf hoort niet bij deze VM zonder token.
Healthcheck
Section titled “Healthcheck”Coolify blijft op GET /robots.txt. Dat pad raakt de database niet, short-circuited in middleware vóór de sessielookup, en vraagt geen auth. / raakt de database en zou Coolify in een restart-loop duwen bij een Postgres-hik.
GET /api/platform/health bestaat voor de master (taak 2.2). Het vereist X-Platform-Key en antwoordt altijd HTTP 200, met database: "ok" of "unreachable" in de body. Zet dit pad niet als Coolify-healthcheck: zonder key is het 401 (restart-loop), en met key blijft het 200 als Postgres plat ligt.
Migraties
Section titled “Migraties”packages/db/scripts/migrate.mjs zit in het image onder /app/scripts/migrate.mjs, met SQL-files onder /app/drizzle. Lokaal: pnpm --filter @platform/db run migrate (ongewijzigd).
De pre-deployment migratie loopt niet bij de éérste deploy. Coolify voert dat
commando uit met docker exec in een al draaiende container. Bij een nieuwe
tenant is die er nog niet, dus het log zegt
Pre-deployment command: No running containers found. Skipping. en de deploy
meldt finished. Vastgesteld bij de oplevering van rb-media op 13 augustus
2026.
Sinds taak 2.5 (T25-4) draait POST /api/tenants/:slug/provision de eerste
migratie via de tenant-DSN (applyTenantMigrations) vóór Coolify start. Dat
haalt de handmatige docker exec voor nieuwe tenants weg.
Sinds taak 2.8 is het master dashboard de primaire weg voor bestaande
tenants. /migrations toont per tenant de schemastand, gelezen uit
drizzle.__drizzle_migrations van de tenant zelf en vergeleken met de
migraties die in het master-image zitten, en past ze sequentieel toe met een
resultaat per database. Elke run krijgt een rij in migration_runs; per
tenant komt tenant.migrated of tenant.migration_failed in de activiteit.
Eén run tegelijk (advisory lock op de master-database); een tenant die faalt
stopt de rest niet. De migratieverbinding heeft lock_timeout 10 s en
statement_timeout 5 min als defaults in applyTenantMigrations, dus ook
voor het pre-deployment-commando hieronder.
Releasevolgorde bij een schemawijziging:
- Master bouwen en deployen (Master-deploy), zodat de bundel de nieuwe migratie bevat.
/migrations→ Alles bijwerken, of eerst één tenant als canary. Groen betekent: elke databaseBij.- Sites uitrollen (2.9). Migraties zijn daarom additief: de site-versie die nú draait moet met het nieuwe schema blijven werken.
Geblokkeerd · database loopt vóór op dit image betekent dat het master-image
ouder is dan het site-image; dan eerst stap 1. diverged betekent dat een
gecommit .sql-bestand achteraf is gewijzigd of dat de boekhouding is
aangeraakt. Drizzle zelf merkt dat niet (het leest alleen de nieuwste
created_at), de runner wel. Beide worden nooit gemigreerd.
Het Coolify pre-deployment-commando blijft staan als idempotent vangnet voor latere deploys. Het past dezelfde SQL toe uit het site-image; komen master en site-image uit dezelfde commit, dan is het een no-op. Handmatige fallback blijft hetzelfde pad:
docker exec <app-container> node /app/scripts/migrate.mjsBehandel bij een nieuwe tenant “deploy is groen” nog steeds niet als bewijs dat
het schema er staat zonder verify, tot de provision-run tenant.provision_partial
of een latere active-markering toont.
Buiten 2.5 / wat 2.6 wél doet. De provisioner schrijft nu één
domains-rij type: preview en zet Coolify domains. Hij roept nog geen
bootstrapTenant / createCmsUser aan, en PATCHt de Coolify per-database
backup-lijst niet. Die backup-lijst blijft een operatorstap tot de OpenAPI daar
een stabiel endpoint voor heeft. Wildcard-TLS (DNS-01) is documentatie, geen
API-aanroep. Het master dashboard zelf draait nog lokaal (losse TASKS-punt).
Op het detailscherm (T25-5) start de operator de run met Provisioneren.
Zonder Coolify blijft de tenant op provisioning met
tenant.provision_partial in de activity-feed.
Provisioning van content
Section titled “Provisioning van content”Na de eerste deploy. De ops-CLI draait op je eigen machine, niet in de
container: het zijn tsx-scripts uit packages/ops en het image bevat alleen
migrate.mjs en drizzle/.
Verbinding naar de Coolify-Postgres
Section titled “Verbinding naar de Coolify-Postgres”De database is niet publiek exposed, dus tunnel een lokale poort. Zet op de Postgres-resource in Coolify tijdelijk een Public Port (5432) en open dan een tunnel; laat dat venster open staan:
ssh -N -L 5433:localhost:5432 root@<server-ip>nc -vz 127.0.0.1 5433 # moet "succeeded" zeggen vóór je verder gaatZonder Public Port kan de tunnel ook naar het container-IP wijzen
(docker inspect -f '{{range .NetworkSettings.Networks}}{{.IPAddress}} {{end}}' <container>
op de server, dan -L 5433:<ip>:5432).
Twee dingen die hier misgaan:
- Het linkergetal van
-Lis de poort die inDATABASE_URLhoort (5433), niet de 5432 van de server. - Ziet
ncde poort wél maar faalt de CLI, dan print het ssh-venster de reden (channel N: open failed: connect failed). Dat betekent dat er op de server niets op 5432 luistert.
Zet de Public Port na de provisioning weer uit, anders staat de klantdatabase open op het internet.
Commando’s
Section titled “Commando’s”pnpm --filter draait het script met werkmap packages/ops, dus --config is
relatief daaraan: ../../tenants/<id>.json.
DATABASE_URL='postgresql://tenant_<id>:…@127.0.0.1:5433/tenant_<id>' \ pnpm --filter @platform/ops run bootstrap -- --config ../../tenants/<id>.json --dry-run
DATABASE_URL='…' pnpm --filter @platform/ops run create-user -- \ --email … --name "…" --role admin --yesEerst --dry-run, dan dezelfde regel met --yes. Bootstrap leest de
applied-migrations-tabel en stopt op een lege database: de pre-deployment
migratie moet dus geslaagd zijn.
Wachtwoorden uit provision-tenant-db.sh komen uit openssl rand -base64 24 en
bevatten vaak +, / of =. Percent-encode die in de URL (%2B, %2F, %3D),
anders krijg je een authenticatiefout die niets met het wachtwoord te maken lijkt
te hebben. Zet de URL tussen enkele aanhalingstekens.
Zie Operations voor de go-live-checklist en restore-procedure. Fase-overgang naar het master dashboard: Freeze-check.