Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 9 additions & 5 deletions .github/workflows/promotion-guard.yml
Original file line number Diff line number Diff line change
Expand Up @@ -3,21 +3,25 @@ name: Promotion Guard
# Enforces the promotion ladder so environment branches can only receive merges
# from the tier directly below them:
#
# dev -> staging -> beta -> main
# dev -> beta -> main
#
# (dev is the team integration + final-QA environment; beta is a disposable
# infra rehearsal for selected testers; main is production. Staging was dropped —
# per-PR preview envs + dev cover pre-beta testing.)
#
# GitHub rulesets can require a review and status checks, but they cannot
# restrict which *source* branch a PR merges from. This guard fills that gap:
# it fails any PR into a protected branch whose head is not the allowed source.
# Mark the "guard" check as a required status check in each branch's ruleset
# (staging, beta, main) for it to actually block a merge.
# (beta, main) for it to actually block a merge.
#
# PRs into a protected branch must come from a branch in this repo (not a fork).
# Emergency bypass: add the `hotfix` label to skip the ladder — allowed only on
# PRs targeting `main`, for a critical fix that must land out of order.

on:
pull_request:
branches: [main, beta, staging]
branches: [main, beta]
types: [opened, reopened, synchronize, edited, labeled, unlabeled]

permissions:
Expand All @@ -42,7 +46,7 @@ jobs:
set -euo pipefail

# Allowed source for each protected target branch.
declare -A ALLOWED=( [main]="beta" [beta]="staging" [staging]="dev" )
declare -A ALLOWED=( [main]="beta" [beta]="dev" )

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick win

Update the stale staging reference.

The mapping is correct, but the nearby rationale still mentions staging <- dev. Change it to beta <- dev so the workflow documentation matches the enforced ladder.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In @.github/workflows/promotion-guard.yml at line 49, Update the nearby
rationale for the ALLOWED mapping in the workflow to reference “beta <- dev”
instead of “staging <- dev,” preserving the existing mapping and workflow
behavior.

expected="${ALLOWED[$BASE]:-}"

echo "PR: $HEAD -> $BASE (from $HEAD_REPO)"
Expand Down Expand Up @@ -71,7 +75,7 @@ jobs:
fi

if [ "$HEAD" != "$expected" ]; then
echo "::error::Promotion ladder violation: '$BASE' only accepts merges from '$expected', but this PR is from '$HEAD'. Follow the ladder dev -> staging -> beta -> main, or add the 'hotfix' label (main only) to bypass."
echo "::error::Promotion ladder violation: '$BASE' only accepts merges from '$expected', but this PR is from '$HEAD'. Follow the ladder dev -> beta -> main, or add the 'hotfix' label (main only) to bypass."
exit 1
fi

Expand Down
122 changes: 82 additions & 40 deletions TWIZRR_SELF_HOST_DEPLOYMENT.md
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@ with **Cloudflare** in front and the database staying on **managed Neon**.
Cloudflare ── DNS + CDN + DDoS + WAF (proxied, Full-strict TLS)
│ (origin reachable ONLY from Cloudflare IPs)
┌────────────┴─────────────────────────────┐
│ One VPS (Hetzner CX42 / Hostinger KVM4)
│ One VPS (Contabo VPS 8 / Hetzner CX43)
│ │
│ Openship control plane + OpenResty edge │ :80/:443 (firewalled to CF)
│ ├─ web (Next.js, apps/web) │ loopback only
Expand All @@ -45,13 +45,29 @@ money-critical data + backups are never our responsibility on the box.
| Database | **Neon** (managed) — the box only runs **stateless** app containers |
| Ingress | **Openship edge + Cloudflare proxied**, origin firewalled to Cloudflare IPs |
| Payments at launch | **Paystack only** — `MONNIFY_*_ENABLED=false` |
| Rollout | **Staging first**, then production |
| Rollout | **Beta first** (disposable rehearsal box), then a **separate dedicated** production box |
| Environments on the box | **Beta only** to start; prod gets its **own** box once beta proves the stack |

**The three guardrails that make a young platform safe:**
1. **DB on Neon** — if Openship ever breaks, no customer data was on it.
2. **Pin Openship to a tagged release** (`OPENSHIP_VERSION`), not `main`.
3. **Stand up on `staging` first** and run real flows (auth, Paystack **test**
checkout, feed, product pages) before production exists.
3. **Stand up `beta` first** — a **disposable** infra rehearsal (Paystack **test**
keys, throwaway data) — and run real flows (auth, test checkout, feed, product
pages) before production exists on its own box.

**Environment model (post-staging-drop):**

| Tier | Who | Where | Data |
| --- | --- | --- | --- |
| **dev + per-PR previews** | Team — integration & final QA (this replaces staging) | Vercel previews / ephemeral Neon | throwaway |
| **beta** | Selected testers — infra rehearsal of the self-host stack | The VPS (this box) | **disposable**, Paystack test |
| **main / prod** | Real users; early access via **allowlist** | A **separate dedicated** box (later) | real, Paystack live |

**No beta→prod data migration.** Beta and prod are separate Neon databases and
nothing crosses automatically; beta runs sandbox payments, so its financial state
can never become real money. Real early-access users therefore live on **prod
behind an allowlist** from day one — "exiting beta" just opens the gate, and no
data is ever migrated. The beta box stays a disposable rehearsal.

---

Expand All @@ -62,14 +78,28 @@ runtime; 3 frontend instances; `socket.io` WebSocket growth):

| Target | Spec | Provider option | ~Cost/mo |
| --- | --- | --- | --- |
| **Recommended (holds long)** | **8 vCPU / 16 GB / 160 GB NVMe** | **Hetzner CX42** (EU) | ~€17 (~$18) |
| Vendor-consolidated | 4 vCPU / 16 GB / 200 GB NVMe | **Hostinger KVM 4** (check renewal price) | ~$13 |
| Budget floor | 4 vCPU / 8 GB / 80 GB NVMe | Hetzner CX32 | ~€7 |

| **Beta rehearsal (current pick)** | **8 vCPU / 24 GB / 300 GB SSD** | **Contabo Cloud VPS 8** (EU) | ~€14 **month-to-month** (no prepay) |
| Recommended for prod (consistent) | 8 vCPU / 16 GB / 160 GB NVMe | **Hetzner CX43** (EU) | ~€16 incl. IPv4 (~$17) |
| Budget floor | 4 vCPU / 8 GB / 80 GB NVMe | Hetzner CX33 | ~€8 |

- **Two-box end state:** the current box runs **beta only** (rehearsal); when the
stack is proven, **prod gets its own dedicated box** — either promote this box
to prod-only (fresh OS reinstall) and move beta to a cheap box, or buy a fresh
prod box. Never run prod cohabiting with beta long-term (blast radius, noisy
neighbor, wider attack surface).
- **Contabo notes:** cheap because oversubscribed → variable vCPU/disk performance
and slow provisioning/support. Fine for **beta**. Order **month-to-month, 1
IPv4** — the beta box needs only **one origin IP**; all its surfaces
(`beta`, `app.beta`, `admin.beta`, `api.beta`) sit behind that single IP via the
edge's host-based routing. (The separate **prod** box later gets its **own**
origin IP.) **Do not prepay 24 months** on a box you may rebuild. If prod later
wants consistent performance, Hetzner is the pick — but then Contabo's numbers
won't perfectly predict prod's.
- **Region:** EU (no budget provider has African DCs; Cloudflare edge serves
static close to Nigerian users; API latency is EU-ish, which is fine).
- **Add the provider's automated snapshots (~$1–2/mo)** — the box is a single
point of failure; snapshots + Neon data + Openship config = fast rebuild.
- **Add the provider's automated snapshots / Auto Backup (~€1–5/mo)** — the box is
a single point of failure; snapshots + Neon data + Openship config = fast
rebuild. (Data itself lives on Neon; box backups only protect config/state.)

---

Expand All @@ -78,7 +108,7 @@ runtime; 3 frontend instances; `socket.io` WebSocket growth):
- [ ] VPS purchased (spec above), **Ubuntu 24.04 LTS**, SSH key added (no password login).
- [ ] Cloudflare zone `twizrr.com` active (nameservers already moved — see the
Cloudflare setup done separately).
- [ ] Neon connection strings ready for **staging** (`ep-mute-glitter`) and later
- [ ] Neon connection strings ready for **beta** (`ep-winter-hall`) and later
**production** (`ep-morning-mountain`). Pull with
`npx neonctl connection-string <branch> --project-id cold-star-22806085`.
- [ ] A pinned Openship version chosen (latest tagged release, e.g. `v0.3.0`).
Expand Down Expand Up @@ -254,18 +284,18 @@ backend reaches it by service name via `REDIS_URL`.
### 8.4 Environment variables (per app)
Set these in each Openship app (secrets stay in Openship, never in git).

**Environment-specific values differ between staging and production** — do not
reuse production domains/keys on staging or the staging smoke test (Phase 5)
**Environment-specific values differ between beta and production** — do not
reuse production domains/keys on beta or the beta smoke test (Phase 5)
will fail (CORS blocks, live charges). Use this matrix for the vars that change:

| Var | Staging | Production |
| Var | Beta | Production |
| --- | --- | --- |
| `DATABASE_URL` / `DIRECT_URL` | Neon **staging** (`ep-mute-glitter`) | Neon **production** (`ep-morning-mountain`) |
| `CORS_ORIGINS` | `https://staging.twizrr.com,https://app.staging.twizrr.com,https://admin.staging.twizrr.com` | `https://twizrr.com,https://app.twizrr.com,https://admin.twizrr.com` |
| `WEB_URL` / `APP_URL` / `FRONTEND_URL` | `*.staging.twizrr.com` | prod hosts |
| `AUTH_COOKIE_DOMAIN` | `staging.twizrr.com` | `twizrr.com` |
| `DATABASE_URL` / `DIRECT_URL` | Neon **beta** (`ep-winter-hall`) | Neon **production** (`ep-morning-mountain`) |
| `CORS_ORIGINS` | `https://beta.twizrr.com,https://app.beta.twizrr.com,https://admin.beta.twizrr.com` | `https://twizrr.com,https://app.twizrr.com,https://admin.twizrr.com` |
| `WEB_URL` / `APP_URL` / `FRONTEND_URL` | `https://beta.twizrr.com` / `https://app.beta.twizrr.com` (each var → its surface's full origin) | `https://twizrr.com` / `https://app.twizrr.com` |
| `AUTH_COOKIE_DOMAIN` | `beta.twizrr.com` | `twizrr.com` |
| `PAYSTACK_*` / `NEXT_PUBLIC_PAYSTACK_PUBLIC_KEY` | **test** keys | **live** keys |
| `NEXT_PUBLIC_API_URL` | `https://api.staging.twizrr.com` | `https://api.twizrr.com` |
| `NEXT_PUBLIC_API_URL` | `https://api.beta.twizrr.com` | `https://api.twizrr.com` |
Comment on lines 297 to +298

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔒 Security & Privacy | 🟠 Major | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "Files matching deployment docs:"
git ls-files | rg -n 'TWIZRR_SELF_HOST_DEPLOYMENT\.md|deploy|deployment|paystack|PAYSTACK' || true

echo
echo "Relevant lines in TWIZRR_SELF_HOST_DEPLOYMENT.md around 260-320:"
if [ -f TWIZRR_SELF_HOST_DEPLOYMENT.md ]; then
  sed -n '260,320p' TWIZRR_SELF_HOST_DEPLOYMENT.md | cat -n -v
fi

echo
echo "Search for PAYSTACK_WEBHOOK_SECRET in repository:"
rg -n "PAYSTACK_WEBHOOK_SECRET|paystack|PAYSTACK|webhook" TWIZRR_SELF_HOST_DEPLOYMENT.md . --glob '!node_modules' --glob '!dist' --glob '!build' | head -200

Repository: coded-devs/twizrr

Length of output: 28248


🌐 Web query:

Paystack webhook signature verification secret key different for sandbox live webhook secret key dashboard

💡 Result:

Yes, Paystack uses different secret keys for its sandbox (test) and live environments [1][2]. Because the webhook signature is generated using your secret key [3][4], the key used to verify the signature must match the environment from which the webhook was sent [1][2]. When configuring your webhook handler, ensure that your application uses the corresponding secret key based on the environment: 1. Test Mode: Use your Test Secret Key (prefixed with sk_test_) to verify signatures for webhooks received in the test environment [1][2]. 2. Live Mode: Use your Live Secret Key (prefixed with sk_live_) to verify signatures for webhooks received in the production environment [1][2]. You can find both sets of keys in your Paystack Dashboard under Settings > API Keys & Webhooks [1][2][4]. It is recommended to use environment variables to manage these keys securely and prevent the accidental use of test keys in production [5][6]. Failure to use the correct key for the respective environment will result in signature verification errors [5].

Citations:


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "Search Paystack secret/env var usage:"
rg -n "PAYSTACK_SECRET_KEY|PAYSTACK_WEBHOOK_SECRET|secretKey|webhook" apps backend packages apps/backend/src --glob '!node_modules' --glob '!dist' --glob '!build' 2>/dev/null | head -250 || true

echo
echo "Relevant deployment webhook instructions around line 343:"
sed -n '330,350p' TWIZRR_SELF_HOST_DEPLOYMENT.md | cat -n -v

Repository: coded-devs/twizrr

Length of output: 34443


Make PAYSTACK_WEBHOOK_SECRET beta/production-specific.

The backend falls back to PAYSTACK_SECRET_KEY when no webhook secret is set, and Paystack signs webhooks with each environment’s secret key. Keep PAYSTACK_WEBHOOK_SECRET separate from the beta and production secret keys, set them in twizrr-backend only for the matching environment, and configure the beta and prod dashboard webhook URLs separately.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@TWIZRR_SELF_HOST_DEPLOYMENT.md` around lines 295 - 296, Update the deployment
environment-variable documentation so PAYSTACK_WEBHOOK_SECRET is explicitly
configured separately for beta and production, rather than relying on
PAYSTACK_SECRET_KEY fallback. Document setting each webhook secret only in the
matching twizrr-backend environment and list distinct Paystack dashboard webhook
URLs for beta and production.

Source: MCP tools


**Backend (`twizrr-backend`)** — everything above, plus (same on both envs):
- `REDIS_URL` → the internal Openship Redis service
Expand All @@ -280,10 +310,10 @@ matrix, plus the surface flags that differ per app:

---

## 9. Phase 5 — Deploy on STAGING first
## 9. Phase 5 — Deploy on BETA first

1. Point the backend's `DATABASE_URL`/`DIRECT_URL` at the **staging Neon branch** (`ep-mute-glitter`).
2. Track the **`staging`** git branch in each Openship app (push-to-deploy).
1. Point the backend's `DATABASE_URL`/`DIRECT_URL` at the **beta Neon branch** (`ep-winter-hall`).
2. Track the **`beta`** git branch in each Openship app (push-to-deploy).
3. Deploy:

```bash
Expand All @@ -292,10 +322,10 @@ openship init # link directory → project (confirm per-app flow in doc
openship deploy
```

4. Attach **staging domains** in Cloudflare (proxied): `staging.twizrr.com`,
`app.staging.twizrr.com`, `admin.staging.twizrr.com`, `api.staging.twizrr.com` → VPS IP.
5. **Smoke test on staging:**
- [ ] `api.staging.twizrr.com/health` OK (Redis reachable)
4. Attach **beta domains** in Cloudflare (proxied): `beta.twizrr.com`,
`app.beta.twizrr.com`, `admin.beta.twizrr.com`, `api.beta.twizrr.com` → VPS IP.
5. **Smoke test on beta:**
- [ ] `api.beta.twizrr.com/health` OK (Redis reachable)
- [ ] Create/edit a product; publish + post-to-feed
- [ ] Auth (login/signup), feed renders, product detail renders
- [ ] Paystack **test** checkout end-to-end
Expand All @@ -305,17 +335,25 @@ openship deploy

## 10. Phase 6 — Production go-live

Only after staging is signed off.
Only after beta is signed off. **Production runs on its own dedicated box** (§3
two-box end state) — provision it and run Phases 1–3 on it, or promote the beta
box to prod-only via a fresh OS reinstall (moving beta to a cheap box first).

1. **Neon:** confirm the production branch (`ep-morning-mountain`) is migrated to
the current schema and seeded (reference data + `bootstrap:admin`).
2. **Backend env → production Neon**; confirm **Paystack live** keys +
`MONNIFY_*_ENABLED=false`; Paystack dashboard webhook → `api.twizrr.com`.
3. Track the **`main`** branch in the production Openship apps; deploy.
4. **Cut the domains:** in Cloudflare, repoint `twizrr.com`, `www`, `app`,
`admin`, `api` from the old Vercel/Railway targets to the **VPS IP** (proxied).
5. **Verify:** `/health`, all three surfaces, a real login, a live checkout.
6. Keep the old Vercel/Railway deploys **parked** (not deleted) for the soak window.
4. **Early access via allowlist, not migration.** Real selected users sign up
**directly on prod** behind an allowlist/invite flag — their data is prod data
from day one. **Nothing migrates from beta** (separate DB, sandbox payments).
"Exiting beta" = opening the gate; there is no cutover of user data.
5. **Cut the domains:** in Cloudflare, repoint `twizrr.com`, `www`, `app`,
`admin`, `api` from the old Vercel/Railway targets to the **prod VPS IP**
(proxied). Give `dev` its own host (e.g. `dev.twizrr.com`, team-gated) so it no
longer squats on the apex.
6. **Verify:** `/health`, all three surfaces, a real login, a live checkout.
7. Keep the old Vercel/Railway deploys **parked** (not deleted) for the soak window.

---

Expand All @@ -332,18 +370,22 @@ Only after staging is signed off.

## 12. Promotion pipeline mapping

The `dev → staging → beta → main` ladder is unchanged — only the deployer swaps
from Vercel/Railway to Openship:
The ladder is now `dev → beta → main` (staging dropped). The deployer swaps from
Vercel/Railway to Openship:

| Git branch | Neon branch | Openship target |
| --- | --- | --- |
| `dev` | dev (`ep-round-silence`) | local/preview (optional) |
| `staging` | staging (`ep-mute-glitter`) | staging apps |
| `beta` | beta (`ep-winter-hall`) | beta apps (optional box/env) |
| `main` | production (`ep-morning-mountain`) | production apps |
| `dev` | dev (`ep-round-silence`) | Vercel previews / local (team) |
| `beta` | beta (`ep-winter-hall`) | **beta apps on this VPS** (disposable rehearsal) |
| `main` | production (`ep-morning-mountain`) | **production apps on a dedicated box** (later) |

> **`staging` is retired.** Its git branch and Neon branch (`ep-mute-glitter`) go
> dormant — keep them parked (Neon scales to zero) or delete once you're sure.
> Per-PR preview envs + `dev` cover pre-beta testing.

Each Openship app tracks one branch; a push re-runs the pipeline for that
environment. The GitHub **promotion-guard** workflow still enforces the ladder.
environment. The GitHub **promotion-guard** workflow enforces the `dev → beta →
main` ladder.

---

Expand Down Expand Up @@ -383,7 +425,7 @@ accumulates on the new stack.

- **First limits you'll hit:** WebSocket concurrency (RAM + FDs — already raised)
and build-vs-runtime contention.
- **Vertical:** bump the VPS (CX42 → 16 vCPU / 32 GB) — a resize + reboot.
- **Vertical:** bump the VPS (CX43 → CX53: 16 vCPU / 32 GB) — a resize + reboot.
- **Then split concerns:** dedicated build, or move Redis to managed (Upstash),
or run frontend and backend on separate boxes.
- **Consolidate frontends** (store+app one runtime, admin separate) to reclaim RAM.
Expand All @@ -395,7 +437,7 @@ accumulates on the new stack.

The docs were being filled out at time of writing. Confirm before relying on:
- Exact `openship.json` schema (monorepo subdir, build/start, port, per-app env).
- Per-branch / multi-environment mapping (staging vs prod apps).
- Per-branch / multi-environment mapping (beta vs prod apps).
- Version pinning mechanism (`OPENSHIP_VERSION`) and upgrade/rollback commands.
- Certificate issuance flow with Cloudflare proxied (HTTP-01 timing).
- Whether to use Openship-managed Redis vs an external managed Redis.
Loading