chore(deploy): retarget launch checklist from Netlify to Dokploy on Hetzner
Some checks failed
Test / vitest (push) Successful in 11m42s
Test / playwright (push) Failing after 9m53s
Test / visual (push) Failing after 9m28s
Test / Notify on failure (push) Successful in 2s

Production hosting is Dokploy on Hetzner, not Netlify. The Nitro app
itself is host-agnostic — Dockerfile + Traefik-aware OIDC + xff-aware
rate limiting were already in place — but the Deploy checklist and the
daily reconcile cron were Netlify-specific.

- LAUNCH_READINESS.md: split Deploy checklist into one-time host setup
  + cutover; replace "Netlify scheduled function" with a Dokploy
  Scheduled Task (curl + X-Reconcile-Token); call out the BASE_URL
  exact-match origin gotcha at customer.post.js:11-15 and the
  NODE_ENV=production requirement.
- Delete netlify.toml and netlify/functions/reconcile-payments.mjs.
  The Nitro route at server/api/internal/reconcile-payments.post.js
  stays — it's host-agnostic; only the trigger moves into Dokploy.

No code changes. validate-env.js still hard-fails on missing
MONGODB_URI / JWT_SECRET / RESEND_API_KEY / HELCIM_API_TOKEN at boot.
Tests: 758 passing, 2 skipped, 0 failing.
This commit is contained in:
Jennie Robinson Faber 2026-04-26 12:29:48 +01:00
parent 8e76ce9366
commit c149fba13a
3 changed files with 31 additions and 68 deletions

View file

@ -30,32 +30,48 @@ None outstanding.
## Deploy checklist ## Deploy checklist
Applies when the site is connected to Netlify / production hosting. Nothing here is actionable until that connection exists; kept here so nothing gets forgotten at cutover. Applies when the app is deployed to **Dokploy on Hetzner**. Build is via the in-repo `Dockerfile` (`node:20-alpine`, runs `node .output/server/index.mjs` on port 3000); Dokploy autodetects it. Traefik (Dokploy's reverse proxy) handles SSL; `oidc-provider.ts:194` and the rate-limit middleware already trust `X-Forwarded-Proto` / `X-Forwarded-For`.
### One-time host setup
- [ ] **Provision the Dokploy app** pointing at this repo. Build context: repo root. Default Dockerfile. Container port: `3000`.
- [ ] **Set env vars in the Dokploy UI** (full list below). The `validate-env.js` Nitro plugin fails fast at boot if `MONGODB_URI` / `JWT_SECRET` / `RESEND_API_KEY` / `HELCIM_API_TOKEN` are missing — container refuses to start, so misconfig surfaces immediately in logs.
- [ ] **`BASE_URL` must exactly match the public origin** (e.g. `https://ghostguild.org`, no trailing slash). The `/api/helcim/customer` origin check at `server/api/helcim/customer.post.js:11-15` does exact-match comparison against the `Origin` header — if `BASE_URL` is wrong or unset, signup 403s.
- [ ] **`NODE_ENV=production`** must be set. Without it: `Secure` cookie flag, HSTS, and CSP all silently no-op.
- [ ] **Add a Dokploy Scheduled Task** for daily reconciliation. Command:
```
curl -fsS -X POST "$BASE_URL/api/internal/reconcile-payments" -H "X-Reconcile-Token: $NUXT_RECONCILE_TOKEN"
```
Schedule: `0 4 * * *` (or any time of day). The Nitro route does the heavy lifting (Mongo iteration, Helcim API, retries) — the scheduler just wakes it up.
### Cutover
- [ ] Push local `main` to `origin/main`. - [ ] Push local `main` to `origin/main`.
- [ ] **Run `node scripts/migrate-contribution-amount.cjs --apply` against prod Mongo BEFORE the new code serves traffic.** Idempotent; dry-run on local counted 34 members. Requires `MONGODB_URI` in env. The script writes `contributionAmount` (Number) derived from existing `contributionTier` (String) on every Member doc; the old field is left intact for a window. - [ ] **Run `node scripts/migrate-contribution-amount.cjs --apply` against prod Mongo BEFORE the new code serves traffic.** Idempotent; dry-run on local counted 34 members. Requires `MONGODB_URI` in env. The script writes `contributionAmount` (Number) derived from existing `contributionTier` (String) on every Member doc; the old field is left intact for a window.
- [ ] Set `NUXT_HELCIM_MONTHLY_PLAN_ID=50302` in production env. - [ ] Set `NUXT_HELCIM_MONTHLY_PLAN_ID=50302` in Dokploy env.
- [ ] Set `NUXT_HELCIM_ANNUAL_PLAN_ID=50303` in production env. - [ ] Set `NUXT_HELCIM_ANNUAL_PLAN_ID=50303` in Dokploy env.
- [ ] **Run `node scripts/reconcile-helcim-payments.mjs --apply` against prod Mongo AFTER the new code serves traffic** to backfill Payment records for pre-existing members. Idempotent (unique `helcimTransactionId`); safe to re-run as a nightly reconciliation job post-launch. - [ ] **Set `NUXT_RECONCILE_TOKEN`** to any 32+ char random string. Shared secret between the Dokploy scheduled task and `/api/internal/reconcile-payments`.
- [ ] Deploy.
- [ ] **Run `node scripts/reconcile-helcim-payments.mjs --apply` against prod Mongo AFTER the new code serves traffic** to backfill Payment records for pre-existing members. Idempotent (unique `helcimTransactionId`); the daily Dokploy cron picks it up from there.
- [ ] **Prod audit for pre-fix series-pass bypass registrations.** Fixed in `f34b062` + `4e1888a` (2026-04-20). Before that, child events of pass-only series (`tickets.requiresSeriesTicket=true && tickets.allowIndividualEventTickets=false`) accepted drop-in registrations from non-pass-holders. For every such series, list its child-event `registrations` where the registrant is not in the parent series' pass-holder list, filter to `registeredAt < 2026-04-20`, and decide per-case: grandfather (keep + notify), refund + unregister, or silently unregister. Local Mongo was scrubbed of 2 such rows on 2026-04-20; prod was intentionally untouched. - [ ] **Prod audit for pre-fix series-pass bypass registrations.** Fixed in `f34b062` + `4e1888a` (2026-04-20). Before that, child events of pass-only series (`tickets.requiresSeriesTicket=true && tickets.allowIndividualEventTickets=false`) accepted drop-in registrations from non-pass-holders. For every such series, list its child-event `registrations` where the registrant is not in the parent series' pass-holder list, filter to `registeredAt < 2026-04-20`, and decide per-case: grandfather (keep + notify), refund + unregister, or silently unregister. Local Mongo was scrubbed of 2 such rows on 2026-04-20; prod was intentionally untouched.
- [ ] **Helcim dashboard: disable the default payment-confirmation email for plans 50302 + 50303.** We send our own CRA-safe confirmation via Resend (`server/emails/paymentConfirmation.js`) triggered from `upsertPaymentFromHelcim`; leaving Helcim's default on = duplicate emails. - [ ] **Helcim dashboard: disable the default payment-confirmation email for plans 50302 + 50303.** We send our own CRA-safe confirmation via Resend (`server/emails/paymentConfirmation.js`) triggered from `upsertPaymentFromHelcim`; leaving Helcim's default on = duplicate emails.
- [ ] **Run one real test charge on staging** via the cloudflared tunnel and verify (a) a Payment doc in Mongo with `amount`, `paymentType`, `status: 'success'`, and (b) exactly one CRA-compliant confirmation email (charity name + "not an official donation receipt" disclaimer; no banned assertive phrasing). - [ ] **Run one real test charge against the deployed app** and verify (a) a Payment doc in Mongo with `amount`, `paymentType`, `status: 'success'`, and (b) exactly one CRA-compliant confirmation email (charity name + "not an official donation receipt" disclaimer; no banned assertive phrasing).
- [ ] **Rotate HELCIM_API_TOKEN** in the Helcim merchant portal and update the prod env var. The token was previously exposed in `window.__NUXT__` payload until today's deploy. - [ ] **Rotate HELCIM_API_TOKEN** in the Helcim merchant portal and update the Dokploy env var. The token was previously exposed in `window.__NUXT__` payload until commit `208638e`.
- [ ] **Set NUXT_RECONCILE_TOKEN** in production env (any 32+ char random string). Used as shared secret between Netlify scheduled function and the internal reconcile route. - [ ] **Trigger the daily reconcile task once manually** in Dokploy to confirm scheduled task + token are wired correctly. Expect a `[reconcile] done {...}` log line.
- [ ] **Verify Netlify scheduled function `reconcile-payments` is enabled** in the Netlify dashboard. Schedule: daily.
**Env vars required in production (reference):** **Env vars required in Dokploy (reference):**
- `NODE_ENV=production`
- `BASE_URL` (exact public origin, no trailing slash)
- `MONGODB_URI` - `MONGODB_URI`
- `JWT_SECRET` (or `NUXT_JWT_SECRET` — the `NUXT_` variant wins) - `JWT_SECRET` (or `NUXT_JWT_SECRET` — the `NUXT_` variant wins)
- `RESEND_API_KEY` - `RESEND_API_KEY`
- `HELCIM_API_TOKEN` - `HELCIM_API_TOKEN`
- `NUXT_HELCIM_MONTHLY_PLAN_ID` - `NUXT_HELCIM_MONTHLY_PLAN_ID=50302`
- `NUXT_HELCIM_ANNUAL_PLAN_ID` - `NUXT_HELCIM_ANNUAL_PLAN_ID=50303`
- `SLACK_BOT_TOKEN`
- `BASE_URL`
- `OIDC_COOKIE_SECRET`
- `NUXT_PUBLIC_HELCIM_PORTAL_URL` - `NUXT_PUBLIC_HELCIM_PORTAL_URL`
- `NUXT_RECONCILE_TOKEN` - `NUXT_RECONCILE_TOKEN` (32+ char random string)
- `SLACK_BOT_TOKEN`
- `OIDC_COOKIE_SECRET`
--- ---

View file

@ -1,13 +0,0 @@
[build]
command = "npm run build"
publish = "dist"
[functions]
directory = "netlify/functions"
# Daily reconciliation cron — invokes the protected Nitro route to upsert
# Payment docs from Helcim transactions. Schedule is also pinned inline in
# netlify/functions/reconcile-payments.mjs (Netlify accepts either; both is
# harmless).
[functions."reconcile-payments"]
schedule = "@daily"

View file

@ -1,40 +0,0 @@
/**
* Netlify scheduled function daily reconciliation of Helcim payments.
*
* Calls the protected Nitro route `/api/internal/reconcile-payments` with the
* shared-secret header. Heavy lifting (Mongo queries, Helcim API calls, retry
* logic) lives in the Nitro handler so it can use auto-imported utils.
*
* Required env (set in Netlify dashboard):
* - URL (set automatically by Netlify)
* - RECONCILE_TOKEN (must match NUXT_RECONCILE_TOKEN in Nitro runtime config)
*
* Schedule: @daily (00:00 UTC). Also pinned in netlify.toml.
*/
export default async () => {
const url = `${process.env.URL}/api/internal/reconcile-payments`
const token = process.env.RECONCILE_TOKEN
if (!token) {
const msg = '[reconcile] RECONCILE_TOKEN not configured; aborting'
console.error(msg)
return new Response(msg, { status: 500 })
}
const res = await fetch(url, {
method: 'POST',
headers: { 'X-Reconcile-Token': token }
})
const body = await res.text()
if (!res.ok) {
console.error('[reconcile] route failed', res.status, body)
return new Response(body, { status: res.status })
}
console.log('[reconcile] ok', body)
return new Response(body, { status: 200 })
}
export const config = {
schedule: '@daily'
}