# Production setup

This is the accepted launch topology ([ADR-0001](../decisions/0001-production-hosting.md)).
Local loops stay in [Local development](./local-dev.md). Secrets stay in
host env and Cloudflare/AWS consoles — never in git or these pages.

```mermaid
flowchart LR
  Users[Operators and public visitors]
  CF[Cloudflare DNS TLS CDN]
  App[Lightsail Next PWA]
  API[Lightsail Express API]
  RDS[(RDS PostgreSQL)]
  R2[Cloudflare R2]
  Amp[Amplify docs]
  Pay[Paystack]

  Users --> CF
  CF --> App
  CF --> API
  CF --> Amp
  App --> API
  API --> RDS
  API --> R2
  API --> Pay
```

| Piece | Product | Role |
|-------|---------|------|
| DNS, TLS, CDN | Cloudflare | `re360muse.com`, cache public assets |
| Files | Cloudflare R2 | Listing photos, uploads. S3 API |
| App + API | AWS Lightsail | Next.js + Express, long-lived Node |
| Database | AWS RDS PostgreSQL | System of record. Prisma `DATABASE_URL` |
| Docs | AWS Amplify | This site (`amplify.yml`) |
| Payments | Paystack | Secret on the API only. Public HMAC webhook |

**Hyperdrive is not in this path.** It does not host Postgres. The API
does not connect through it. See [Hyperdrive](./hyperdrive.md).

Prefer an AWS region closer to operators than `ca-central-1` (for
example `eu-west-1` or `eu-central-1`). Cape Town (`af-south-1`) is
optional if budget allows.

## 1. RDS PostgreSQL

Same AWS **region** as Lightsail (prefer `eu-west-1` / `eu-central-1`
over `ca-central-1`). Engine is **PostgreSQL** — not MySQL, not Aurora
unless you already know you want it.

### Console

1. RDS → **Databases** → **Create database**.
2. **Standard create**. Engine **PostgreSQL**, version **16**.
3. Template **Dev/Test** for launch (single-AZ, cheaper). Use
   **Production** if the instance must be Multi-AZ from day one (roughly
   double instance cost, automatic failover). You can Modify to Multi-AZ
   later without rebuilding the schema.
4. Identifier `re360muse`. Master user e.g. `re360muse_admin`. Generate
   a password and store it in a password manager — not git.
5. Instance **Burstable**, `db.t4g.micro` (cheapest) or `db.t4g.small`.
6. Storage **gp3**, 20 GiB, **Enable storage autoscaling** (cap ~100 GiB).
7. Connectivity:
   - VPC: default is fine.
   - **Public access: No.**
   - VPC security group: create `re360muse-rds`.
   - Do **not** add `0.0.0.0/0` on 5432.
8. Additional configuration: initial database name `re360muse`.
9. Backups: retention **7** days. Encryption: default KMS. Turn
   **Deletion protection** on.
10. Create. Wait until **Available**. Copy the endpoint hostname
    (`….rds.amazonaws.com`), port **5432**.

Creating the instance takes several minutes.

### Let Lightsail reach it

Lightsail is not in the default VPC.

1. Lightsail → **Account** → **Advanced** → enable **VPC peering** for
   this region.
2. Note the Lightsail VPC CIDR on that page.
3. EC2 → **Security Groups** → `re360muse-rds` → Inbound → PostgreSQL
   **5432** → source = that CIDR (or the Lightsail instance private IP).
4. Remove any temporary “My IP” rule after you finish laptop migrations.

To run `prisma migrate` from a laptop without opening the world: SSH to
Lightsail (or a tiny bastion) and run migrate **there**, or add a
temporary inbound rule for **your IP only**, migrate, then delete the
rule.

### App user (not the master)

From a session that can already reach RDS:

```sql
CREATE USER re360muse_app WITH PASSWORD '...';
GRANT CONNECT ON DATABASE re360muse TO re360muse_app;
GRANT USAGE ON SCHEMA public TO re360muse_app;
GRANT CREATE ON SCHEMA public TO re360muse_app;
ALTER DEFAULT PRIVILEGES IN SCHEMA public
  GRANT SELECT, INSERT, UPDATE, DELETE ON TABLES TO re360muse_app;
```

Use `re360muse_app` in `DATABASE_URL`. Keep the master user for console
and break-glass.

### API env and schema

```text
DATABASE_URL=postgresql://re360muse_app:PASSWORD@HOST:5432/re360muse?sslmode=require
NODE_ENV=production
```

On the API host (`main-backend`):

```bash
npx prisma generate
npx prisma migrate deploy
npm run seed
```

Do not run `db push` against production if you have migrations. Do not
point `DATABASE_URL` at Hyperdrive.

Schema changes ship as folders under `prisma/migrations`. Inbox replies
live in `contact_replies` (`20260919043000_contact_inbox_replies`).
Company godfather flags live on `Company.billingExempt`
(`20260919020000_company_billing_exempt`). If the API returns Prisma
`P2021` (table does not exist), the running code is ahead of RDS: copy
the missing migration folder onto
`/opt/re360muse/api/prisma/migrations` if git pull has not landed yet,
then `npx prisma migrate deploy` **on the Lightsail host**.

## 2. Cloudflare R2 (storage)

The API already uses `@aws-sdk/client-s3` and `multer-s3` with
`S3_ENDPOINT`. R2 is a bucket behind that client.

1. Buy R2 on the Cloudflare account.
2. Create a bucket (example name `re360muse-uploads`).
3. **R2 → Manage API Tokens.** Create a token with Object Read & Write
   scoped to that bucket. Save Access Key ID and Secret Access Key once.
4. Endpoint: `https://<ACCOUNT_ID>.r2.cloudflarestorage.com`  
   Jurisdiction buckets use `eu.`, `us.`, or `fedramp.` prefixes — see
   Cloudflare R2 auth docs.
5. Region for the SDK: `auto` (or `us-east-1` if a tool forbids `auto`).

API env:

| Name | Production (R2) | Local (MinIO) |
|------|-----------------|---------------|
| `AWS_ACCESS_KEY_ID` | R2 Access Key ID | MinIO root user |
| `AWS_SECRET_ACCESS_KEY` | R2 Secret Access Key | MinIO secret |
| `AWS_REGION` | `auto` | any |
| `AWS_BUCKET_NAME` | R2 bucket name | local bucket |
| `S3_ENDPOINT` | `https://<ACCOUNT_ID>.r2.cloudflarestorage.com` | `http://localhost:9000` |
| `S3_FORCE_PATH_STYLE` | `false` (usual for R2) | `true` |

Upload routes stay **JWT**. Do not make the bucket world-writable.
Public listing images: custom domain on R2 (`cdn.re360muse.com`) or
signed GET URLs from the API. JWT still gates writes.

Optional: Cloudflare cache rules for `cdn.re360muse.com` (long TTL,
cache everything for immutable object keys).

## 3. Lightsail (app + API)

Console create, first boot, Caddy, systemd, and `scripts/deploy.sh`
are in [Lightsail Node.js host](./lightsail.md). Ubuntu 24.04, same
region as RDS — not the Bitnami Node.js blueprint.

One Linux instance is enough to launch. Two instances (app / API) only
if you want to scale them separately later. Suggested start: 2 vCPU /
4 GB.

On the box: Node 20+, Caddy, systemd `re360muse-api`, code under
`/opt/re360muse/api`. Disk is app code only — no Postgres data
directory, no upload directory as source of truth.

Firewall (Lightsail networking):

- 22 from your IP
- 80/443 from Cloudflare (or from the world if orange-cloud)
- Do not open 5432 on Lightsail
- Do not open 4000/3000 publicly; proxy them
- Do not open 22 to GitHub-hosted runners. API deploys use a
  self-hosted runner on the box (push to `main`). See
  [Lightsail](./lightsail.md).

App env:

- `NEXT_PUBLIC_API_URL` — public API origin, e.g. `https://api.re360muse.com`
- `NEXTAUTH_URL` — `https://re360muse.com`
- `NEXTAUTH_SECRET`
- `NEXT_PUBLIC_WS_APP_URL` — websocket origin (usually the API host)
- `NEXT_PUBLIC_DOCS_URL` — docs host

API env (add to the RDS/R2 set):

- `JWT_SECRET`, `SESSION_SECRET` (generated on first `deploy.sh` with
  `openssl rand -base64 32`; not printed)
- Paystack secret **only here** (passed as `--paystack-secret-key`)
- `CORS_ORIGIN` — `https://re360muse.com` (and `www` if used)
- VAPID keys for push
- `PORT` behind the reverse proxy

CORS in `main-backend` production allowlists
`https://re360muse.com`, `https://www.re360muse.com`, company
subdomains (`https://{slug}.re360muse.com`), extra origins in
`CORS_ORIGIN`, and Professional custom hosts that are `ACTIVE`.
Update `CORS_ORIGIN` if you add another OS origin.

### Scaling later

Lightsail is a fixed size. Resize vertically with downtime. Horizontal
scale needs a load balancer **and** a Socket.io adapter (Redis) because
notifications are in-process today.

When one size-up is not enough: same Docker/Node process on App Runner
or ECS, same RDS, same R2. See [ADR-0001](../decisions/0001-production-hosting.md).

## 4. DNS and TLS

Live today:

| Name | Target | Notes |
|------|--------|-------|
| `@` / `www` | Amplify / CloudFront | OS app and marketing. Apex and `www` are **not** company listing sites. |
| `*` (wildcard `*.re360muse.com`) | Same Amplify app as `www` | Company listing sites at `https://{slug}.re360muse.com`. Add this as an Amplify custom domain so TLS covers company subdomains. Reserved names (`www`, `api`, `docs`, `app`, `admin`, `mail`, `cdn`, `staging`) must never be a company slug. |
| `api` | Lightsail public IP (Route53 A) | Caddy + Let's Encrypt. **Not** covered by the app wildcard. Do not orange-cloud this record. |
| `docs` | Amplify | This site |

Every company with a listing site is served at
`https://{slug}.re360muse.com`. The Next.js proxy rewrites that host
to `/sites/{slug}` (and 301s `/sites/{slug}` on apex/`www` to the
subdomain once the wildcard is live). Local preview stays
`http://localhost:3000/sites/{slug}`.

Professional Website & Listings also connects a customer hostname
(`listings.broker.com`):

1. CNAME the hostname to `{slug}.re360muse.com`.
2. TXT `_re360muse-site.{hostname}` = the site `domainToken`.
3. Verify from Development. Status is `PENDING`, then `ACTIVE` or
   `FAILED` from a real CNAME/TXT lookup.

TLS for arbitrary customer hosts still needs Cloudflare Custom
Hostnames (or adding each domain in Amplify). Do not treat `ACTIVE` as
HTTPS-ready on the customer hostname until that path exists. The
RE360Muse subdomain stays live either way.

Node on the API box listens on `127.0.0.1:4000`. Caddy is the public
HTTPS listener. `/api/docs` is not served in production.

If you later orange-cloud `api` through Cloudflare, use Full (strict)
and an Origin CA (or authenticated origin pulls). Until then, do not
copy a Caddyfile that expects `/etc/caddy/certs/origin.pem`.

Paystack webhook URL is public HTTPS on the API host. HMAC-verified. Do
not put JWT in front of it. See [Security](../architecture/security.md).

Cache HTML for the OS app **off** (or bypass cache for `/properties`,
`/auth`, `/owners`). Cache `cdn` and static `/_next/static` aggressively.
Do not cache HTML for `{slug}.re360muse.com` company sites.

## 5. Amplify docs

This repo already has `amplify.yml`. Connect `re360muse-docs-site` to
Amplify. Chromium is installed in the build spec for Mermaid. Do not
switch that spec to `apt-get`.

`Open OS` and footer app links are baked in at **build time**. The
build spec defaults:

| Name | Default |
|------|---------|
| `RE360MUSE_APP_URL` | `https://re360muse.com` |
| `POKEDOCS_URL` | `https://docs.re360muse.com` |

Set the same names in the Amplify console if the OS host is not
`re360muse.com`. A build without `RE360MUSE_APP_URL` used to bake
`http://localhost:3000` into the live navbar.

## 6. Checklist before first production traffic

- [ ] RDS not publicly reachable
- [ ] `DATABASE_URL` uses SSL
- [ ] R2 writes require JWT
- [ ] Paystack secret only on the API
- [ ] No `.env` in git
- [ ] Cloudflare Full (strict)
- [ ] Prisma `migrate deploy` + seed catalog
- [ ] Webhook URL matches the live API host
- [ ] Socket.io works through the `api` hostname
- [ ] Hyperdrive **not** set as `DATABASE_URL` for Express
- [ ] Amplify `RE360MUSE_APP_URL` is the live OS host (not localhost)
