Files
hackerspace.sh/docs/DEPLOYMENT.md
T

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
Email 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 URL
  • NEXT_PUBLIC_SUPABASE_ANON_KEY — anon JWT, public
  • SUPABASE_SERVICE_ROLE_KEY — service-role JWT, server-side only
  • NEXT_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 with openssl rand -hex 32. Unset means the dispatcher returns 503 and never sends.

How a deploy runs

  1. A developer pushes to main.
  2. GitHub Actions (.github/workflows/deploy.yml) opens an SSH connection to the Droplet using a deploy key stored in repository secrets.
  3. 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 in public._migrations_applied is applied and recorded.
    • Restarts PostgREST to refresh its schema cache.
    • Runs pnpm build.
    • Restarts the hackerspace-app systemd unit.
    • Probes the local app for a 200 response.

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.

  1. Authentication → URL Configuration: add {APP_URL}/auth/confirm to the redirect allowlist (alongside the existing /auth/callback).
  2. Authentication → Providers → Email: keep "Secure email change" enabled (double confirm: both old and new address must verify).
  3. 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.log for deploy outcomes.
  • Verify backups by restoring pg_*.sql.gz to a staging instance quarterly.
  • Rotate the Supabase service-role key and JWT secret if the .env on the Droplet is ever exposed.
  • Keep the host OS patched. The bootstrap installs unattended-upgrades.