Two environments: staging and prod. There is deliberately no
dev environment — staging doubles as dirty-dev and internal-demo space,
which works because tearing it down and standing it back up is routine.
Prod is not stood up yet. Its Terraform inputs are still placeholders, so every prod hostname below is the agreed target rather than a live host.
Hostnames
| Surface | Staging | Prod (target) |
|---|---|---|
Tenant origin — consumer at /, admin at /admin |
staging.curate.memorial |
<slug>.curate.memorial |
| Additional tenants | <slug>.staging.curate.memorial |
<slug>.curate.memorial |
| Platform super-admin | platform-staging.curate.memorial |
admin.curate.memorial |
| Backend API | api-staging.curate.memorial |
api.curate.memorial |
| Marketing + public guides | — | curate.memorial |
| This handbook | — | internal.curate.memorial |
The two content sites are single-environment — one Worker each, no staging copy. They publish content and have nothing to break against a backend.
The staging tenant model
Staging runs one standing internal tenant whose slug is staging, so it
lands on staging.curate.memorial — exactly the shape of a prod tenant
origin. Staging therefore permanently rehearses tenant onboarding instead of
being a special case with its own rules.
A second concurrent tenant — testing cross-tenant isolation, say — gets
<slug>.staging.curate.memorial as its own Custom Domain. Plain
<slug>.curate.memorial stays reserved for prod tenants; staging must not eat
into that namespace. And because staging is on the reserved-slug list, no
customer can ever claim it.
Staging is disposable by design
Its Postgres is a single ECS task with no volume and no backups. Teardown and re-spin take about fifteen minutes and cost effectively nothing while the environment is down. That is the property that lets staging be dirty-dev, demo, and error-reproduction space simultaneously.
Three things follow, and they are features rather than bugs:
- All staging data is gone after a re-spin — tenants, staff, memorials, media. Reseed from the UAT playbook if you need it back.
- Secrets are re-minted, so the bootstrap super-admin password changes.
- One manual DNS edit each time: the ALB gets a new DNS name, so the
Cloudflare
api-stagingrecord has to be repointed.
The frontends are not part of teardown at all. They are Cloudflare
Workers; they keep serving throughout, and their /api/* calls simply fail
until the backend is back. Nothing to do in either direction.
The full procedure is curate-server
docs/DEPLOY-RUNBOOK.md.
How each repo deploys
| Repo | Mechanism | Trigger |
|---|---|---|
curate-server — infrastructure |
Terraform: make apply ENV=<env> |
manual, root AWS credentials |
curate-server — image |
GitLab CI → OIDC → ECR → ECS | push to main, or a manual web pipeline |
curate-tenant |
Cloudflare Workers Builds | push to main |
curate-platform |
Cloudflare Workers Builds | push to main |
curate-content |
Cloudflare Workers Builds, one project per site | push to main |
Terraform owns infra; CI owns the image
The backend’s two paths are decoupled on purpose. The ECS services ignore
changes to their task definition, so terraform apply never rolls a
service, and a CI deploy never introduces an environment variable — it
only swaps the image inside the task definition that already exists.
The consequence to remember: a change needing both a new environment variable and the code that reads it must apply Terraform first. Shipping the image first boots new code against old configuration.
Why the frontends do not deploy from GitLab
Workers Builds keeps the long-lived Cloudflare API token out of GitLab CI variables — wrangler has no OIDC path, unlike the backend’s AWS role. The GitLab pipelines on those repos are merge gates only: typecheck, tests, lint, build.
Non-default branches get preview versions on workers.dev. One caveat worth
knowing: a preview hostname’s first label is not a tenant slug, so previews
cannot exercise tenant resolution — that only becomes testable on a real
Custom Domain.
AWS shape
Everything runs in ap-east-2 (Taipei), one fully isolated stack per
environment, named <env>-<project>-*. An ALB fronts two ECS Fargate services
— the API and the background worker, from the same image — with S3, KMS, and
Secrets Manager alongside. Staging uses Postgres-on-ECS; prod will use RDS.
ALB ingress is locked to Cloudflare’s CIDR ranges, so the origin is not
reachable except through the edge.