Ga naar inhoud

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.

Terminal window
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 ….

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).

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.

  1. 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.yml gebruikt postgres:18-alpine — houd die major gelijk aan Coolify.
  2. Tenant-database + rol via scripts/provision-tenant-db.sh.
  3. Applicatie: build pack Dockerfile, base directory /, Dockerfile /apps/site-template/Dockerfile, poort 4321.
  4. Healthcheck-pad: /robots.txt (geen DB-call; zie hieronder).
  5. Pre-deployment command: node /app/scripts/migrate.mjs — breekt de deploy af bij exit ≠ 0.
  6. Env-variabelen per de tabel hierboven. Zet de vijf public vars als Coolify Build Variable.

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.ts stript een leidende www.).
  • 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).

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.nl en admin.klant.nl blijven binnen die twee niveaus. Zet SSL-modus op Full (strict).
  • De pilot: proxy blijft uit. admin.pilot.okhema.studio is 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.

De provisioner wijst per tenant een preview-hostname toe:

  • Publiek: {slug}.{previewBase} (default previewBase = okhema.studio).
  • CMS: admin.{slug}.{previewBase} — dezelfde regel als adminHost() zonder poort.
  • Coolify domains: https://{hostname},https://{adminHostname}. Dat veld zet de provisioner bij create, en bij een retry via PATCH /api/v1/applications/{uuid} (2.5-apps hadden nog geen domains).
  • SITE_DOMAIN is de hostname zonder scheme. De Coolify-string wordt niet opgeslagen; alleen de domains-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):

  1. Wildcard A-record *.{previewBase} → het server-IP (dus *.okhema.studio of *.preview.okhema.studio).
  2. 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.
  3. 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:

  1. Cloudflare for SaaS aanzetten op de zone die de fallback-origin host.
  2. Fallback-origin (bijv. customers.okhema.studio) naar de Coolify-VPS; TLS daar (of een Cloudflare-cert op die origin).
  3. Custom Hostname-API: POST /zones/{zoneId}/custom_hostnames met ssl.method=http, ssl.type=dv. HTTP-DCV start zodra de CNAME live is.
  4. SSL-modus op de klant-hostname: Full (strict) zodra de origin een geldig cert heeft. custom_origin_sni alleen als de origin een ander cert-CN nodig heeft.

Per klant:

  1. In het dashboard: hostname toevoegen — meestal www.klant.nl.
  2. DNS bij de klant: CNAME {hostname} → {NUXT_CF_CNAME_TARGET}. Apex (klant.nl) vereist CNAME-flattening (Cloudflare DNS, of een ALIAS); anders www. registreren en apex redirecten.
  3. Optionele TXT _cf-custom-hostname.… versnelt ownership-prevalidatie; HTTP-DCV heeft die niet hard nodig.
  4. Controleer status tot “Actief (edge-TLS)”. Dat is het Cloudflare- certificaat, niet canonieke cutover.
  5. Coolify krijgt https://{custom} erbij (volledige replace van domains). 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.

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.

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:

  1. Master bouwen en deployen (Master-deploy), zodat de bundel de nieuwe migratie bevat.
  2. /migrationsAlles bijwerken, of eerst één tenant als canary. Groen betekent: elke database Bij.
  3. 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:

Terminal window
docker exec <app-container> node /app/scripts/migrate.mjs

Behandel 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.

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/.

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:

Terminal window
ssh -N -L 5433:localhost:5432 root@<server-ip>
nc -vz 127.0.0.1 5433 # moet "succeeded" zeggen vóór je verder gaat

Zonder 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 -L is de poort die in DATABASE_URL hoort (5433), niet de 5432 van de server.
  • Ziet nc de 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.

pnpm --filter draait het script met werkmap packages/ops, dus --config is relatief daaraan: ../../tenants/<id>.json.

Terminal window
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 --yes

Eerst --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.