Production setup
This is the accepted launch topology (ADR-0001). Local loops stay in Local development. Secrets stay in host env and Cloudflare/AWS consoles — never in git or these pages.
| 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.
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
- RDS → Databases → Create database.
- Standard create. Engine PostgreSQL, version 16.
- 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.
- Identifier
re360muse. Master user e.g.re360muse_admin. Generate a password and store it in a password manager — not git. - Instance Burstable,
db.t4g.micro(cheapest) ordb.t4g.small. - Storage gp3, 20 GiB, Enable storage autoscaling (cap ~100 GiB).
- Connectivity:
- VPC: default is fine.
- Public access: No.
- VPC security group: create
re360muse-rds. - Do not add
0.0.0.0/0on 5432.
- Additional configuration: initial database name
re360muse. - Backups: retention 7 days. Encryption: default KMS. Turn Deletion protection on.
- 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.
- Lightsail → Account → Advanced → enable VPC peering for this region.
- Note the Lightsail VPC CIDR on that page.
- EC2 → Security Groups →
re360muse-rds→ Inbound → PostgreSQL 5432 → source = that CIDR (or the Lightsail instance private IP). - 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:
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
DATABASE_URL=postgresql://re360muse_app:PASSWORD@HOST:5432/re360muse?sslmode=require
NODE_ENV=production
On the API host (main-backend):
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.
- Buy R2 on the Cloudflare account.
- Create a bucket (example name
re360muse-uploads). - R2 → Manage API Tokens. Create a token with Object Read & Write scoped to that bucket. Save Access Key ID and Secret Access Key once.
- Endpoint:
https://<ACCOUNT_ID>.r2.cloudflarestorage.com
Jurisdiction buckets useeu.,us., orfedramp.prefixes — see Cloudflare R2 auth docs. - Region for the SDK:
auto(orus-east-1if a tool forbidsauto).
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. 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.
App env:
NEXT_PUBLIC_API_URL— public API origin, e.g.https://api.re360muse.comNEXTAUTH_URL—https://re360muse.comNEXTAUTH_SECRETNEXT_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 firstdeploy.shwithopenssl rand -base64 32; not printed)- Paystack secret only here (passed as
--paystack-secret-key) CORS_ORIGIN—https://re360muse.com(andwwwif used)- VAPID keys for push
PORTbehind 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.
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):
- CNAME the hostname to
{slug}.re360muse.com. - TXT
_re360muse-site.{hostname}= the sitedomainToken. - Verify from Development. Status is
PENDING, thenACTIVEorFAILEDfrom 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.
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_URLuses SSL - R2 writes require JWT
- Paystack secret only on the API
- No
.envin git - Cloudflare Full (strict)
- Prisma
migrate deploy+ seed catalog - Webhook URL matches the live API host
- Socket.io works through the
apihostname - Hyperdrive not set as
DATABASE_URLfor Express - Amplify
RE360MUSE_APP_URLis the live OS host (not localhost)