# Full self-hosted deployment on DigitalOcean End-to-end guide to run the entire stack on your own hardware: self-hosted Supabase plus the Next.js app, both on a single DigitalOcean Droplet. No managed Supabase, no Vercel, no third-party SaaS for application data. What you get at the end: - `https://yourdomain.com` serves the Next.js app. - `https://supabase.yourdomain.com` serves the Supabase API gateway (auth, REST, realtime). - `https://studio.yourdomain.com` serves Supabase Studio (password-protected). - Postgres, GoTrue (auth), PostgREST, Realtime, Storage, and Studio all run as Docker containers on the same Droplet. - Automatic SSL via Let's Encrypt. - Nightly Postgres backups to local disk (and optionally DigitalOcean Spaces). If you want the managed-Supabase version instead, see [DEPLOYMENT.md](./DEPLOYMENT.md). That path is simpler but locks you into Supabase Cloud. --- ## Table of contents 1. [Architecture](#1-architecture) 2. [Cost](#2-cost) 3. [Prerequisites](#3-prerequisites) 4. [Provision the Droplet](#4-provision-the-droplet) 5. [Initial server hardening](#5-initial-server-hardening) 6. [Install Docker, Nginx, Certbot](#6-install-docker-nginx-certbot) 7. [DNS records](#7-dns-records) 8. [Self-host Supabase](#8-self-host-supabase) 9. [Apply the app schema and migrations](#9-apply-the-app-schema-and-migrations) 10. [Deploy the Next.js app](#10-deploy-the-nextjs-app) 11. [Nginx reverse proxy + SSL](#11-nginx-reverse-proxy--ssl) 12. [Post-deploy verification](#12-post-deploy-verification) 13. [Backups](#13-backups) 14. [Operations: upgrades, logs, monitoring](#14-operations-upgrades-logs-monitoring) 15. [Hardening checklist](#15-hardening-checklist) 16. [Troubleshooting](#16-troubleshooting) --- ## 1. Architecture ``` Internet | v DigitalOcean Droplet (Ubuntu 24.04) | |-- Nginx (80, 443) | | | |-- yourdomain.com -> localhost:3000 (Next.js app) | |-- supabase.yourdomain.com -> localhost:8000 (Kong, Supabase API gateway) | `-- studio.yourdomain.com -> localhost:3001 (Supabase Studio, basic-auth gated) | |-- Docker network: hackerspace_net | |-- hackerspace_app (Next.js, port 3000) | `-- Docker network: supabase_default |-- supabase-db (Postgres 15) |-- supabase-auth (GoTrue) |-- supabase-rest (PostgREST) |-- supabase-realtime (Realtime) |-- supabase-storage (Storage API) |-- supabase-kong (API gateway, port 8000) |-- supabase-studio (Dashboard, port 3000 -> host 3001) |-- supabase-meta (Postgres meta API) |-- supabase-imgproxy `-- supabase-vector (logs) ``` The Next.js app and Supabase live in separate Docker networks. Nginx is the only public entry point. The app talks to Supabase over `https://supabase.yourdomain.com` (public URL) so it works the same locally and in production. You can also expose Supabase only over the internal docker network and have the app talk to `http://supabase-kong:8000` directly. That is faster but breaks browser-side Supabase calls because the browser cannot resolve internal hostnames. Stick with the public URL pattern unless you understand the trade-off. --- ## 2. Cost | Item | Spec | Monthly | |------|------|---------| | Droplet | 8 GB RAM, 4 vCPU, 160 GB SSD (Regular) | $48 | | Reserved IP | static IPv4 | included | | Domain | from any registrar | ~$1 | | **Total** | | **~$49/mo** | You can run on a 4 GB / 2 vCPU Droplet ($24/mo) if you tune Postgres and disable a few Supabase services (Storage, Edge Functions). 8 GB is the comfort floor. For larger spaces (200+ members, heavy chat, file uploads), split into two Droplets: one 8 GB for Supabase, one 2 GB for the app, joined via a VPC. --- ## 3. Prerequisites - DigitalOcean account. - A domain you control. Two A records (or one A record with two CNAMEs) point to the Droplet IP. See section 7. - An SSH public key on your local machine (`~/.ssh/id_ed25519.pub` or similar). - Local tools: `ssh`, `git`, `curl`, `openssl`, `docker` (for testing builds), `node` (for the JWT generator one-liner). - An SMTP provider for auth emails (Resend, SendGrid, AWS SES, Postmark, Mailgun). Free tiers are fine. You need: - SMTP host, port (587 or 465), username, password - A verified `from` address --- ## 4. Provision the Droplet 1. cloud.digitalocean.com → Droplets → Create. 2. Image: **Ubuntu 24.04 (LTS) x64**. 3. Plan: **Basic — Regular — 8 GB / 4 vCPU / 160 GB SSD**. 4. Datacenter: closest to your users. 5. Authentication: **SSH Key** (add your public key, never use password auth). 6. Hostname: `hackerspace-prod` (or whatever). 7. Click **Create Droplet**. 8. Note the public IPv4 address. Reserve it under Networking → Reserved IPs if you want it permanent. --- ## 5. Initial server hardening SSH in as root: ```bash ssh root@ ``` Update and create a deploy user: ```bash apt-get update && apt-get -y upgrade adduser deploy # set a strong password usermod -aG sudo deploy rsync --archive --chown=deploy:deploy ~/.ssh /home/deploy ``` Disable root SSH and password auth: ```bash sed -i 's/^#\?PermitRootLogin.*/PermitRootLogin no/' /etc/ssh/sshd_config sed -i 's/^#\?PasswordAuthentication.*/PasswordAuthentication no/' /etc/ssh/sshd_config systemctl restart ssh ``` From a NEW terminal (keep the old one open until you confirm), test: ```bash ssh deploy@ ``` If that works, close the root session and continue as `deploy`. Set up a firewall (allow SSH, HTTP, HTTPS only): ```bash sudo ufw allow OpenSSH sudo ufw allow 80/tcp sudo ufw allow 443/tcp sudo ufw enable ``` Confirm: `sudo ufw status`. --- ## 6. Install Docker, Nginx, Certbot ```bash # Docker curl -fsSL https://get.docker.com | sudo sh sudo usermod -aG docker deploy newgrp docker # Compose v2 plugin is bundled with the install above. Verify: docker --version docker compose version # Nginx and Certbot sudo apt-get install -y nginx certbot python3-certbot-nginx git # pnpm for occasional ad-hoc commands (optional; the app runs from Docker) curl -fsSL https://get.pnpm.io/install.sh | sh - ``` Log out and back in so the `docker` group takes effect. --- ## 7. DNS records At your registrar, add A records pointing to the Droplet IP: | Host | Type | Value | |------|------|-------| | `yourdomain.com` | A | `` | | `www.yourdomain.com` | A | `` | | `supabase.yourdomain.com` | A | `` | | `studio.yourdomain.com` | A | `` | Wait for propagation (`dig +short supabase.yourdomain.com` should return your IP). --- ## 8. Self-host Supabase ### 8.1 Clone the Supabase repo ```bash sudo mkdir -p /opt && cd /opt sudo git clone --depth 1 https://github.com/supabase/supabase.git sudo chown -R deploy:deploy /opt/supabase cd /opt/supabase/docker cp .env.example .env ``` ### 8.2 Generate secrets You need four critical secrets: `POSTGRES_PASSWORD`, `JWT_SECRET`, `ANON_KEY`, `SERVICE_ROLE_KEY`. Generate them on your **local machine** (not the Droplet) and paste them into `.env`. ```bash # Strong random Postgres password (32 chars) openssl rand -base64 24 # Strong JWT secret (40+ chars, alphanumeric) openssl rand -hex 32 ``` Now generate the JWTs. Pick the JWT_SECRET you just generated and run this Node one-liner on your local machine: ```bash # Save the JWT_SECRET from above into a variable first. export JWT_SECRET='paste-your-jwt-secret-here' # Generate ANON_KEY (10-year expiry): npx -y -p jsonwebtoken@9 node -e " const jwt = require('jsonwebtoken'); const iat = Math.floor(Date.now() / 1000); const exp = iat + 60 * 60 * 24 * 365 * 10; console.log(jwt.sign({ role: 'anon', iss: 'supabase', iat, exp }, process.env.JWT_SECRET)); " # Generate SERVICE_ROLE_KEY (10-year expiry): npx -y -p jsonwebtoken@9 node -e " const jwt = require('jsonwebtoken'); const iat = Math.floor(Date.now() / 1000); const exp = iat + 60 * 60 * 24 * 365 * 10; console.log(jwt.sign({ role: 'service_role', iss: 'supabase', iat, exp }, process.env.JWT_SECRET)); " ``` Save all four values somewhere safe. The service role key is a god-mode key; treat it like root credentials. Also generate two more secrets: ```bash # Dashboard credentials (for Supabase Studio basic auth) openssl rand -base64 16 # use as DASHBOARD_PASSWORD # Logflare API keys (Supabase logs analytics; required even if you don't use Logflare cloud) openssl rand -hex 32 # use as LOGFLARE_API_KEY openssl rand -hex 32 # use as LOGFLARE_LOGGER_BACKEND_API_KEY ``` ### 8.3 Edit `/opt/supabase/docker/.env` Open the file: `nano /opt/supabase/docker/.env`. Set: ```bash # Postgres POSTGRES_PASSWORD= POSTGRES_PORT=5432 POSTGRES_HOST=db POSTGRES_DB=postgres # JWT JWT_SECRET= JWT_EXPIRY=3600 ANON_KEY= SERVICE_ROLE_KEY= # Public URLs (set to your domain, NOT the Droplet IP) SITE_URL=https://yourdomain.com ADDITIONAL_REDIRECT_URLS=https://yourdomain.com,http://localhost:3000 API_EXTERNAL_URL=https://supabase.yourdomain.com SUPABASE_PUBLIC_URL=https://supabase.yourdomain.com # Studio STUDIO_DEFAULT_ORGANIZATION=hackerspace STUDIO_DEFAULT_PROJECT=hackerspace STUDIO_PORT=3000 # internal; we remap on the host in docker-compose.override.yml DASHBOARD_USERNAME=admin DASHBOARD_PASSWORD= # Kong KONG_HTTP_PORT=8000 KONG_HTTPS_PORT=8443 # SMTP for auth emails (use your provider's values) SMTP_ADMIN_EMAIL=admin@yourdomain.com SMTP_HOST=smtp.resend.com # or sendgrid, ses, etc. SMTP_PORT=587 SMTP_USER=resend SMTP_PASS= SMTP_SENDER_NAME=hackerspace.sh # Auth behavior ENABLE_EMAIL_SIGNUP=true ENABLE_EMAIL_AUTOCONFIRM=false # set to true for dev; false in prod ENABLE_ANONYMOUS_USERS=false ENABLE_PHONE_SIGNUP=false ENABLE_PHONE_AUTOCONFIRM=false # Logs / analytics LOGFLARE_API_KEY= LOGFLARE_LOGGER_BACKEND_API_KEY= LOGFLARE_PUBLIC_ACCESS_TOKEN= # Postgrest PGRST_DB_SCHEMAS=public,storage,graphql_public # Functions (you probably don't use these; keep default) FUNCTIONS_VERIFY_JWT=true # Pooler (Supavisor) — keep defaults unless you know you need to change POOLER_TENANT_ID=hackerspace POOLER_DEFAULT_POOL_SIZE=20 POOLER_MAX_CLIENT_CONN=100 POOLER_DB_POOL_SIZE=5 POOLER_PROXY_PORT_TRANSACTION=6543 POOLER_PROXY_PORT_SESSION=5432 SECRET_KEY_BASE= VAULT_ENC_KEY= ``` Lock the file: ```bash chmod 600 /opt/supabase/docker/.env ``` ### 8.4 Remap Studio off port 3000 The Next.js app uses host port 3000, and Supabase Studio also defaults to host port 3000. Create an override so Studio binds to 3001 instead. ```bash cat > /opt/supabase/docker/docker-compose.override.yml << 'EOF' services: studio: ports: - "127.0.0.1:3001:3000/tcp" kong: ports: - "127.0.0.1:8000:8000/tcp" - "127.0.0.1:8443:8443/tcp" EOF ``` Binding to `127.0.0.1` means these ports are only reachable locally; Nginx will proxy them publicly. The default Supabase compose binds to `0.0.0.0`, which would expose them on the public internet. This override fixes that. ### 8.5 Start Supabase ```bash cd /opt/supabase/docker docker compose pull docker compose up -d docker compose ps # all services should be "running" or "healthy" ``` First boot pulls about 4 GB of images and takes 5–10 minutes. Once everything is up, the API is at `http://localhost:8000` and Studio is at `http://localhost:3001`. Both are private to the host (we bound them to `127.0.0.1`). We'll expose them with Nginx in section 11. ### 8.6 Confirm Postgres is healthy ```bash docker compose exec db psql -U postgres -c '\dt public.*' ``` You should see an empty table list. Postgres is ready; the application schema goes in next. --- ## 9. Apply the app schema and migrations The hackerspace-management app stores everything in Postgres. We apply the schema directly to the self-hosted Postgres instance. ### 9.1 Copy the schema files onto the Droplet From your local machine: ```bash scp scripts/schema.sql deploy@:/tmp/ scp scripts/014_member_user_id_nullable.sql deploy@:/tmp/ scp scripts/015_prevent_member_self_role_change.sql deploy@:/tmp/ ``` (Or `git clone` the repo on the Droplet, see section 10.1.) ### 9.2 Run the schema ```bash cd /opt/supabase/docker # Pipe each file into psql inside the Postgres container. docker compose exec -T db psql -U postgres -d postgres < /tmp/schema.sql docker compose exec -T db psql -U postgres -d postgres < /tmp/014_member_user_id_nullable.sql docker compose exec -T db psql -U postgres -d postgres < /tmp/015_prevent_member_self_role_change.sql ``` No errors expected. Verify: ```bash docker compose exec db psql -U postgres -d postgres -c '\dt public.*' docker compose exec db psql -U postgres -d postgres -c "SELECT count(*) FROM pg_policies WHERE schemaname = 'public';" docker compose exec db psql -U postgres -d postgres -c "SELECT tablename FROM pg_publication_tables WHERE pubname = 'supabase_realtime';" ``` Expect: 13 tables, ~45 policies, `comms_messages` and `comms_channels` in the realtime publication. ### 9.3 Restart PostgREST so it picks up the new schema PostgREST caches the schema on start. After applying tables, restart it: ```bash docker compose restart rest ``` --- ## 10. Deploy the Next.js app ### 10.1 Clone the app repo on the Droplet ```bash sudo mkdir -p /opt/hackerspace sudo chown deploy:deploy /opt/hackerspace cd /opt git clone https://github.com//hackerspace-management.git hackerspace cd hackerspace ``` ### 10.2 Create the production env file ```bash cp .env.example .env.local nano .env.local ``` Fill in: ```bash NEXT_PUBLIC_SUPABASE_URL=https://supabase.yourdomain.com NEXT_PUBLIC_SUPABASE_ANON_KEY= SUPABASE_SERVICE_ROLE_KEY= NEXT_PUBLIC_APP_URL=https://yourdomain.com NODE_ENV=production ``` Lock the file: ```bash chmod 600 /opt/hackerspace/.env.local ``` ### 10.3 Build and start the app The repo ships with a Dockerfile that produces a standalone Next.js image, and a `docker-compose.app.yml` that runs it (app only; `docker-compose.yml` is the all-in-one local stack and is not used here). The Dockerfile expects build-time access to the public Supabase URL and anon key (Next.js inlines `NEXT_PUBLIC_*` at build time), so pass them as build args. ```bash cd /opt/hackerspace docker compose -f docker-compose.app.yml build \ --build-arg NEXT_PUBLIC_SUPABASE_URL=https://supabase.yourdomain.com \ --build-arg NEXT_PUBLIC_SUPABASE_ANON_KEY="" \ --build-arg NEXT_PUBLIC_APP_URL=https://yourdomain.com docker compose -f docker-compose.app.yml up -d docker compose -f docker-compose.app.yml logs -f app ``` Wait for `Ready in `. Then verify locally on the Droplet: ```bash curl http://localhost:3000/api/health # {"status":"ok","timestamp":"..."} ``` The app is now running on `127.0.0.1:3000`. Public access still requires Nginx, which we set up next. --- ## 11. Nginx reverse proxy + SSL Create one server block per public hostname. ### 11.1 App: `yourdomain.com` ```bash sudo nano /etc/nginx/sites-available/hackerspace-app ``` Paste: ```nginx server { listen 80; server_name yourdomain.com www.yourdomain.com; location / { proxy_pass http://127.0.0.1:3000; proxy_http_version 1.1; proxy_set_header Upgrade $http_upgrade; proxy_set_header Connection 'upgrade'; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; proxy_set_header X-Forwarded-Proto $scheme; proxy_cache_bypass $http_upgrade; proxy_read_timeout 86400s; proxy_send_timeout 86400s; } } ``` ### 11.2 Supabase API: `supabase.yourdomain.com` ```bash sudo nano /etc/nginx/sites-available/supabase-api ``` Paste: ```nginx server { listen 80; server_name supabase.yourdomain.com; # Generous body size for storage uploads client_max_body_size 50m; location / { proxy_pass http://127.0.0.1:8000; proxy_http_version 1.1; proxy_set_header Upgrade $http_upgrade; proxy_set_header Connection 'upgrade'; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; proxy_set_header X-Forwarded-Proto $scheme; # Realtime / WebSockets proxy_read_timeout 86400s; proxy_send_timeout 86400s; } } ``` ### 11.3 Studio (admin dashboard): `studio.yourdomain.com` Studio is a god-mode dashboard. Gate it behind HTTP basic auth in addition to its own login. ```bash sudo apt-get install -y apache2-utils sudo htpasswd -c /etc/nginx/.htpasswd_studio admin # set a strong password sudo nano /etc/nginx/sites-available/supabase-studio ``` Paste: ```nginx server { listen 80; server_name studio.yourdomain.com; # Restrict to your office/home IPs if you have a static one. # allow 1.2.3.4; # deny all; auth_basic "Restricted"; auth_basic_user_file /etc/nginx/.htpasswd_studio; location / { proxy_pass http://127.0.0.1:3001; proxy_http_version 1.1; proxy_set_header Upgrade $http_upgrade; proxy_set_header Connection 'upgrade'; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; proxy_set_header X-Forwarded-Proto $scheme; } } ``` ### 11.4 Enable and reload ```bash sudo ln -s /etc/nginx/sites-available/hackerspace-app /etc/nginx/sites-enabled/ sudo ln -s /etc/nginx/sites-available/supabase-api /etc/nginx/sites-enabled/ sudo ln -s /etc/nginx/sites-available/supabase-studio /etc/nginx/sites-enabled/ sudo rm -f /etc/nginx/sites-enabled/default sudo nginx -t sudo systemctl reload nginx ``` ### 11.5 SSL with Let's Encrypt Make sure all four hostnames resolve to the Droplet before running: ```bash sudo certbot --nginx \ -d yourdomain.com -d www.yourdomain.com \ -d supabase.yourdomain.com \ -d studio.yourdomain.com ``` Pick option `2` (redirect HTTP to HTTPS) when prompted. Certbot rewrites the Nginx configs to add `listen 443 ssl;` blocks and sets up auto-renewal via a systemd timer. Confirm renewal works: ```bash sudo certbot renew --dry-run ``` --- ## 12. Post-deploy verification Run through this list. Everything should pass on the first try. ```bash # 1. App health curl https://yourdomain.com/api/health # -> {"status":"ok","timestamp":"..."} # 2. Supabase API health (Kong should reject without an apikey) curl https://supabase.yourdomain.com/rest/v1/ # -> {"hint":"...","message":"Invalid API key"} or similar (proves Kong is reachable) # 3. Supabase API with anon key (should return empty array or a hint) curl https://supabase.yourdomain.com/rest/v1/spaces \ -H "apikey: " -H "Authorization: Bearer " # -> [] (empty because RLS filters out everything for an unauthenticated request) # 4. Studio # Open https://studio.yourdomain.com in a browser. Pass basic auth (admin / your password). # Studio's own login uses DASHBOARD_USERNAME / DASHBOARD_PASSWORD. ``` App flow: - [ ] Visit `https://yourdomain.com/signup`. Create an account, create a space, land on `/dashboard`. - [ ] In Studio, Table Editor, `space_members`: confirm a row exists with `role = 'admin'`, `status = 'current'`. - [ ] `comms_channels`: confirm three rows (`general`, `announcements`, `ops`). - [ ] Open `/comms`, send a message, open a second tab, watch the message appear without refresh (realtime works). - [ ] In Studio, run as the `anon` role: ```sql UPDATE public.space_members SET role = 'admin' WHERE user_id = auth.uid(); ``` This must fail with `Members cannot change their own role, tier, status, approval, card access, or space.` That confirms migration 015 is active. --- ## 13. Backups ### 13.1 Daily Postgres dump to local disk ```bash sudo mkdir -p /var/backups/supabase sudo chown deploy:deploy /var/backups/supabase cat > /opt/hackerspace/backup-db.sh << 'EOF' #!/bin/bash set -euo pipefail TS=$(date +%Y%m%d_%H%M%S) DEST=/var/backups/supabase/postgres_${TS}.sql.gz cd /opt/supabase/docker docker compose exec -T db pg_dump -U postgres --clean --if-exists postgres | gzip > "$DEST" # Keep last 14 days find /var/backups/supabase -name "postgres_*.sql.gz" -mtime +14 -delete echo "[$(date)] backup -> $DEST ($(du -h "$DEST" | cut -f1))" EOF chmod +x /opt/hackerspace/backup-db.sh ``` Schedule daily at 3 AM: ```bash ( crontab -l 2>/dev/null; echo "0 3 * * * /opt/hackerspace/backup-db.sh >> /var/log/db-backup.log 2>&1" ) | crontab - ``` ### 13.2 Off-site backups to DigitalOcean Spaces (recommended) Local backups die with the Droplet. Push them to Spaces (S3-compatible). 1. Spaces dashboard → Create Space → note the bucket name and endpoint URL. 2. Spaces → Access Keys → generate one. Save the key and secret. 3. Install `s3cmd`: ```bash sudo apt-get install -y s3cmd s3cmd --configure # Endpoint: nyc3.digitaloceanspaces.com (or your region) # Use HTTPS, no encryption ``` 4. Add an upload line to the backup script: ```bash echo "s3cmd put $DEST s3:///db/" >> /opt/hackerspace/backup-db.sh ``` 5. Lifecycle policy on the Space: rotate to cold storage / delete after N days. ### 13.3 Restoring ```bash gunzip -c /var/backups/supabase/postgres_20260514_030000.sql.gz | \ docker compose -f /opt/supabase/docker/docker-compose.yml exec -T db psql -U postgres -d postgres ``` Test restores at least quarterly on a staging Droplet. A backup you have not restored is not a backup. --- ## 14. Operations: upgrades, logs, monitoring ### 14.1 App upgrades ```bash cd /opt/hackerspace git pull origin main docker compose -f docker-compose.app.yml build \ --build-arg NEXT_PUBLIC_SUPABASE_URL=https://supabase.yourdomain.com \ --build-arg NEXT_PUBLIC_SUPABASE_ANON_KEY="" \ --build-arg NEXT_PUBLIC_APP_URL=https://yourdomain.com docker compose -f docker-compose.app.yml up -d --no-deps app docker image prune -f ``` Wrap in `/opt/hackerspace/deploy.sh` and trigger from GitHub Actions on push to `main` if you want CI auto-deploy. ### 14.2 Database migrations When the app ships a new `scripts/NNN_*.sql`, apply it: ```bash cd /opt/supabase/docker docker compose exec -T db psql -U postgres -d postgres < /opt/hackerspace/scripts/NNN_*.sql docker compose restart rest # PostgREST caches the schema ``` Never re-run `scripts/schema.sql` against a live database; it is the canonical full file for fresh deploys. Use the numbered incrementals on existing databases. ### 14.3 Supabase upgrades ```bash cd /opt/supabase/docker git fetch origin && git pull origin master docker compose pull docker compose up -d ``` Read the [release notes](https://github.com/supabase/supabase/releases) before pulling major versions. The Supabase docker-compose has occasional breaking env-var renames. ### 14.4 Logs ```bash # App docker compose -f /opt/hackerspace/docker-compose.app.yml logs -f app # Supabase cd /opt/supabase/docker docker compose logs -f kong auth rest realtime docker compose logs -f db | grep -v 'connection received' # noisy # Nginx sudo journalctl -u nginx -f sudo tail -f /var/log/nginx/access.log ``` ### 14.5 Resource monitoring ```bash # Quick view docker stats # Disk df -h docker system df # Postgres size docker compose -f /opt/supabase/docker/docker-compose.yml exec db \ psql -U postgres -c "SELECT pg_size_pretty(pg_database_size('postgres'));" ``` For real monitoring, install [Netdata](https://www.netdata.cloud/) (`bash <(curl -Ss https://my-netdata.io/kickstart.sh)`) or wire DigitalOcean Monitoring to send alerts on CPU > 80%, disk > 80%, or memory > 85%. --- ## 15. Hardening checklist Before you point real users at this: - [ ] Root SSH disabled, password auth disabled (section 5). - [ ] UFW allows only 22, 80, 443. - [ ] All four hostnames have valid Let's Encrypt certs (`sudo certbot certificates`). - [ ] `/opt/hackerspace/.env.local` and `/opt/supabase/docker/.env` are mode 600, owned by `deploy`. - [ ] `SERVICE_ROLE_KEY` is set as a Docker secret or env var only, never logged. - [ ] Studio (`studio.yourdomain.com`) is behind HTTP basic auth and ideally IP-allowlisted. - [ ] `DASHBOARD_PASSWORD` is strong and stored in a password manager. - [ ] `JWT_SECRET` is 32+ random bytes, never committed. - [ ] Migration 015 (`prevent_member_self_role_change`) is applied (verify by trying the self-promotion query in section 12). - [ ] Daily Postgres backups running, off-site copies enabled. - [ ] `unattended-upgrades` installed for OS security patches: `sudo apt-get install -y unattended-upgrades && sudo dpkg-reconfigure -plow unattended-upgrades` - [ ] Fail2Ban for SSH: `sudo apt-get install -y fail2ban && sudo systemctl enable --now fail2ban` - [ ] SMTP configured and sending: trigger a password reset email and confirm it arrives. - [ ] Realtime works end-to-end (verified in section 12). - [ ] Disk has at least 50 GB free; Postgres grows over time. --- ## 16. Troubleshooting ### App returns 500 on every page Almost always wrong env vars. Confirm `/opt/hackerspace/.env.local` has the four required values and that `NEXT_PUBLIC_SUPABASE_URL` exactly matches the URL you used in `API_EXTERNAL_URL` in the Supabase `.env`. `docker compose -f /opt/hackerspace/docker-compose.app.yml logs app | tail -50` will surface the actual error. ### "Invalid API key" from every Supabase call The `ANON_KEY` you put in the app does not match the `JWT_SECRET` Supabase signed it with, or you regenerated `JWT_SECRET` without regenerating the keys. Regenerate both JWTs from the current `JWT_SECRET`, paste into both env files, restart both stacks. ### Signup succeeds but `/dashboard` redirects to `/signup` forever The signup completed in `auth.users` but `space_members` insertion failed (most often migration 014 not applied). Check Studio → Authentication → Users (you should see the new user) and Table Editor → `space_members` (you should see a row). If the row is missing, the trigger or the `createSpace` server action returned an error, look in the app logs. ### Realtime is silent Two things to check: 1. `comms_messages` and `comms_channels` are in the realtime publication: ```sql SELECT tablename FROM pg_publication_tables WHERE pubname = 'supabase_realtime'; ``` 2. Nginx is forwarding WebSocket upgrades. `proxy_set_header Upgrade $http_upgrade;` and `proxy_set_header Connection 'upgrade';` must be present in the `supabase-api` server block. ### `pnpm build` works locally but fails in Docker Check that the `NEXT_PUBLIC_*` build args were passed (section 10.3). Next.js inlines those at build time; if missing, the client bundle ships with `undefined` for them and the app reports "Failed to fetch" on every Supabase call. ### Postgres uses 100% CPU Run `pg_stat_activity` from Studio to find the offender: ```sql SELECT pid, state, query_start, wait_event_type, wait_event, left(query, 200) FROM pg_stat_activity WHERE state != 'idle' ORDER BY query_start; ``` Kill runaway queries with `SELECT pg_terminate_backend();`. Add an index. Common culprit: missing index on `comms_messages(channel_id, created_at)` for chat history; the schema already has this, so check that the index exists (`\di idx_comms_messages_*` in psql). ### Out of memory Supabase realtime and Postgres are the heaviest. Bump the Droplet up one tier. Or, if you do not use Storage / Functions / Edge Runtime, comment those services out of `/opt/supabase/docker/docker-compose.yml`. Easy 1–1.5 GB saved. ### Renewal of SSL certs failed ```bash sudo systemctl status certbot.timer sudo journalctl -u certbot | tail -50 ``` Most common cause: a hostname stopped resolving. Confirm `dig +short ` returns the Droplet IP for every hostname Certbot manages. --- ## Appendix: keeping the app and Supabase versions in sync The application is versioned per its own `package.json`. Supabase is versioned per the `supabase/supabase` repo's tags. They evolve independently. - The app pins `@supabase/ssr` and `@supabase/supabase-js` in `package.json`. If you upgrade Supabase server-side (in `/opt/supabase/docker`), check the [Supabase release notes](https://github.com/supabase/supabase/releases) for breaking changes in the JS client. - Run `pnpm update @supabase/ssr @supabase/supabase-js` in a feature branch, run the test suite (`pnpm vitest run`), redeploy. For the application-level schema, the source of truth is `scripts/schema.sql` plus the numbered incrementals. The Supabase stack does not manage application schemas; it only provides Postgres and the layers on top. --- This document covers the full self-hosted path. If anything here drifts from `docs/DEPLOYMENT.md` (the managed-Supabase guide), this document is the source of truth for self-hosted deployments.