Skip to main content

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.

Operators and public visitors

Cloudflare DNS TLS CDN

Lightsail Next PWA

Lightsail Express API

RDS PostgreSQL

Cloudflare R2

Amplify docs

Paystack

PieceProductRole
DNS, TLS, CDNCloudflarere360muse.com, cache public assets
FilesCloudflare R2Listing photos, uploads. S3 API
App + APIAWS LightsailNext.js + Express, long-lived Node
DatabaseAWS RDS PostgreSQLSystem of record. Prisma DATABASE_URL
DocsAWS AmplifyThis site (amplify.yml)
PaymentsPaystackSecret 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​

  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:

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.

  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:

NameProduction (R2)Local (MinIO)
AWS_ACCESS_KEY_IDR2 Access Key IDMinIO root user
AWS_SECRET_ACCESS_KEYR2 Secret Access KeyMinIO secret
AWS_REGIONautoany
AWS_BUCKET_NAMER2 bucket namelocal bucket
S3_ENDPOINThttps://<ACCOUNT_ID>.r2.cloudflarestorage.comhttp://localhost:9000
S3_FORCE_PATH_STYLEfalse (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.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.

4. DNS and TLS​

Live today:

NameTargetNotes
@ / wwwAmplify / CloudFrontOS app and marketing. Apex and www are not company listing sites.
* (wildcard *.re360muse.com)Same Amplify app as wwwCompany 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.
apiLightsail public IP (Route53 A)Caddy + Let's Encrypt. Not covered by the app wildcard. Do not orange-cloud this record.
docsAmplifyThis 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.

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:

NameDefault
RE360MUSE_APP_URLhttps://re360muse.com
POKEDOCS_URLhttps://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)