---
title: "Environments"
description: "Staging and prod — the hostnames, how each repo deploys, and why staging is disposable on purpose."
---

> Documentation Index
> Fetch the complete documentation index at: https://internal.curate.memorial/llms.txt
> Use this file to discover all available pages before exploring further.

# Environments

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-staging` record 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`](https://gitlab.com/revere-curate/curate-server/-/blob/main/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.

Source: https://internal.curate.memorial/architecture/environments/index.mdx
