7.5 KiB
Deployment
The supported production target is a single DigitalOcean Droplet running self-hosted Supabase plus the Next.js app behind Caddy with automatic HTTPS. The end-to-end procedure lives in DEPLOY_DO_SELFHOSTED.md.
At a glance
| Component | Hosted on |
|---|---|
| Database (Postgres) | Supabase container on the Droplet, data on a persistent block volume |
| Auth (GoTrue) | Supabase container on the Droplet |
| REST and Realtime | Supabase containers on the Droplet |
| App (Next.js) | systemd service on the Droplet, port 3000 |
| Reverse proxy | Caddy 2 on the Droplet, ports 80 and 443 |
| Resend (SMTP) | |
| Backups | Daily pg_dumpall to /mnt/data/backups, 14-day retention |
| Continuous deployment | GitHub Actions runs /opt/hackerspace-ops/deploy.sh over SSH on every push to main |
Required environment variables
All variables are listed in .env.example. At minimum:
NEXT_PUBLIC_SUPABASE_URL— public Supabase API URLNEXT_PUBLIC_SUPABASE_ANON_KEY— anon JWT, publicSUPABASE_SERVICE_ROLE_KEY— service-role JWT, server-side onlyNEXT_PUBLIC_APP_URL— public URL of the deployed app, used in OAuth redirects
Optional:
NEXT_PUBLIC_OAUTH_GITHUB/NEXT_PUBLIC_OAUTH_GOOGLE— set to"true"only for an OAuth provider you have actually configured in Supabase Auth. The login page hides any provider button that is not enabled, and hides the whole social-sign-in block if neither is set. These are build-time public values, so a change requires a redeploy to take effect.RESEND_API_KEY— Resend HTTP API key for transactional email. Unset means the notification outbox still fills but nothing is sent (dispatcher records each row as failed with "transport not configured").EMAIL_FROM— from address for all platform email, e.g.HeatSync Labs <[email protected]>. The domain must be verified in Resend (SPF + DKIM) for production delivery.CRON_SECRET— shared secret guarding the notification dispatcher. Generate withopenssl rand -hex 32. Unset means the dispatcher returns 503 and never sends.
How a deploy runs
- A developer pushes to
main. - GitHub Actions (
.github/workflows/deploy.yml) opens an SSH connection to the Droplet using a deploy key stored in repository secrets. - The action invokes
/opt/hackerspace-ops/deploy.sh, which:- Fetches latest
main. - Runs
pnpm install --frozen-lockfile. - Iterates over
scripts/0*.sql. Any file not present inpublic._migrations_appliedis applied and recorded. - Restarts PostgREST to refresh its schema cache.
- Runs
pnpm build. - Restarts the
hackerspace-appsystemd unit. - Probes the local app for a 200 response.
- Fetches latest
Migrations are idempotent. A redeploy that introduces no new migrations is a no-op on the database.
Local development
See LOCAL_DEV.md for the fresh-clone-to-running sequence using the Supabase CLI.
First-time provisioning
For initial server provisioning (Docker, the Supabase stack, Caddy, firewall, SSH hardening, deploy-key registration, the systemd unit, backup cron), follow DEPLOY_DO_SELFHOSTED.md end to end.
Notification dispatcher cron
Transactional email is an outbox drained by POST /api/cron/notifications. Add a once-a-minute root crontab entry on the Droplet (after RESEND_API_KEY, EMAIL_FROM, CRON_SECRET are set in the app .env):
CRON_SECRET=<the same value set in .env.production>
* * * * * curl -fsS -m 30 -X POST http://127.0.0.1:3000/api/cron/notifications -H "Authorization: Bearer $CRON_SECRET" >/dev/null 2>&1
Define CRON_SECRET= on its own line at the top of the crontab. A crontab runs with a bare environment, so a bare $CRON_SECRET reference expands to empty and every call silently 401s (the >/dev/null 2>&1 hides it). It hits the app locally (bypassing the reverse proxy), self-throttles under Resend's rate limit, and is idempotent, so a missed or overlapping minute is harmless. Migration 041_notifications.sql is applied automatically by deploy.sh like every other scripts/0*.sql; no manual database step.
Door inbound-log poll cron (optional, door epic Phase 4)
If a space runs a native-HeatSync controller and turns on inbound ingest (per connection, on /door/manage), the once-a-minute poll pulls its ?z log into the access log and matches each card to a member. It reuses the same CRON_SECRET; add a second crontab entry under the same inline CRON_SECRET= definition as above:
* * * * * curl -fsS -m 30 -X POST http://127.0.0.1:3000/api/cron/door-ingest -H "Authorization: Bearer $CRON_SECRET" >/dev/null 2>&1
No new env var. The route returns 503 if CRON_SECRET is unset and 401 on mismatch, polls only connections with inbound_enabled + adapter='native_heatsync', and is idempotent (overlapping or missed minutes are harmless; events dedupe on (connection_id, dedupe_key)). Generic (non-HeatSync) controllers do NOT use this poll; they push to the per-connection webhook instead (POST /api/door/inbound/[connection], authenticated by the connection's bearer secret from the vault — no env var, no crontab; the webhook URL is shown on /door/manage when inbound is enabled). Migration 053_door_inbound_ingest.sql applies automatically with the rest.
Member email change (Supabase Auth config — required)
The self-serve "change my login email" feature (/me Profile tab) needs Supabase project configuration that is NOT in code. Until this is set, requesting an email change will send a link that does not land correctly.
- Authentication → URL Configuration: add
{APP_URL}/auth/confirmto the redirect allowlist (alongside the existing/auth/callback). - Authentication → Providers → Email: keep "Secure email change" enabled (double confirm: both old and new address must verify).
- Authentication → Email Templates → "Change Email Address": point the link at the confirm route with the recovery-style token, not the OAuth code flow:
<a href="{{ .SiteURL }}/auth/confirm?token_hash={{ .TokenHash }}&type=email_change">Confirm this email change</a>
With Secure email change on, Supabase sends this to both the old and new address; each link hits /auth/confirm, which verifyOtps and (post-verification only) syncs the denormalized space_members.email. Email delivery uses the project's configured SMTP (or Supabase built-in if none); production should use a real SMTP provider.
Error monitoring (optional)
The app reports server-side errors (the Stripe webhook, both cron dispatchers, and every server action via onRequestError) to any Sentry-compatible backend named by the SENTRY_DSN env var. Unset = the capture seam no-ops, so this is fully optional and costs nothing when off. All event content is secret-scrubbed before send (emails, JWTs, Stripe/Resend keys, bearer tokens).
To self-host the backend, deploy/glitchtip/ is a batteries-included GlitchTip stack (own Postgres + Redis, 127.0.0.1-bound): cp .env.example .env, set the two secrets, docker compose up -d, register a project, then put its DSN in SENTRY_DSN and restart the app. You can equally point SENTRY_DSN at Sentry SaaS and skip the self-hosted stack.
Operational checklist
- Monitor
/var/log/hackerspace-deploy.logfor deploy outcomes. - Verify backups by restoring
pg_*.sql.gzto a staging instance quarterly. - Rotate the Supabase service-role key and JWT secret if the
.envon the Droplet is ever exposed. - Keep the host OS patched. The bootstrap installs
unattended-upgrades.