Files
hackerspace.sh/docs/DEPLOY_DO_SELFHOSTED.md
Superminaren d81a49175b
CI / check (push) Canceled after 0s
CI / integration-e2e (push) Canceled after 0s
Deploy to production / deploy (push) Canceled after 0s
Updated
2026-10-14 23:30:02 +02:00

29 KiB
Raw Permalink Blame History

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. That path is simpler but locks you into Supabase Cloud.


Table of contents

  1. Architecture
  2. Cost
  3. Prerequisites
  4. Provision the Droplet
  5. Initial server hardening
  6. Install Docker, Nginx, Certbot
  7. DNS records
  8. Self-host Supabase
  9. Apply the app schema and migrations
  10. Deploy the Next.js app
  11. Nginx reverse proxy + SSL
  12. Post-deploy verification
  13. Backups
  14. Operations: upgrades, logs, monitoring
  15. Hardening checklist
  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:

ssh root@<droplet-ip>

Update and create a deploy user:

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:

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:

ssh deploy@<droplet-ip>

If that works, close the root session and continue as deploy.

Set up a firewall (allow SSH, HTTP, HTTPS only):

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

# 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 <droplet-ip>
www.yourdomain.com A <droplet-ip>
supabase.yourdomain.com A <droplet-ip>
studio.yourdomain.com A <droplet-ip>

Wait for propagation (dig +short supabase.yourdomain.com should return your IP).


8. Self-host Supabase

8.1 Clone the Supabase repo

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.

# 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:

# 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:

# 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:

# Postgres
POSTGRES_PASSWORD=<from step 8.2>
POSTGRES_PORT=5432
POSTGRES_HOST=db
POSTGRES_DB=postgres

# JWT
JWT_SECRET=<from step 8.2>
JWT_EXPIRY=3600
ANON_KEY=<JWT from step 8.2>
SERVICE_ROLE_KEY=<JWT from step 8.2>

# 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=<from step 8.2>

# 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=<your SMTP API key>
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=<from step 8.2>
LOGFLARE_LOGGER_BACKEND_API_KEY=<from step 8.2>
LOGFLARE_PUBLIC_ACCESS_TOKEN=<reuse LOGFLARE_API_KEY or generate another>

# 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=<openssl rand -hex 32>
VAULT_ENC_KEY=<openssl rand -hex 32>

Lock the file:

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.

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

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

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:

scp scripts/schema.sql deploy@<droplet-ip>:/tmp/
scp scripts/014_member_user_id_nullable.sql deploy@<droplet-ip>:/tmp/
scp scripts/015_prevent_member_self_role_change.sql deploy@<droplet-ip>:/tmp/

(Or git clone the repo on the Droplet, see section 10.1.)

9.2 Run the schema

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:

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:

docker compose restart rest

10. Deploy the Next.js app

10.1 Clone the app repo on the Droplet

sudo mkdir -p /opt/hackerspace
sudo chown deploy:deploy /opt/hackerspace
cd /opt
git clone https://github.com/<owner>/hackerspace-management.git hackerspace
cd hackerspace

10.2 Create the production env file

cp .env.example .env.local
nano .env.local

Fill in:

NEXT_PUBLIC_SUPABASE_URL=https://supabase.yourdomain.com
NEXT_PUBLIC_SUPABASE_ANON_KEY=<ANON_KEY from step 8.2>
SUPABASE_SERVICE_ROLE_KEY=<SERVICE_ROLE_KEY from step 8.2>
NEXT_PUBLIC_APP_URL=https://yourdomain.com
NODE_ENV=production

Lock the file:

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.

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="<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 <ms>. Then verify locally on the Droplet:

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

sudo nano /etc/nginx/sites-available/hackerspace-app

Paste:

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

sudo nano /etc/nginx/sites-available/supabase-api

Paste:

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.

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:

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

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:

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:

sudo certbot renew --dry-run

12. Post-deploy verification

Run through this list. Everything should pass on the first try.

# 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: <ANON_KEY>" -H "Authorization: Bearer <ANON_KEY>"
# -> [] (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:
    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

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:

( crontab -l 2>/dev/null; echo "0 3 * * * /opt/hackerspace/backup-db.sh >> /var/log/db-backup.log 2>&1" ) | crontab -

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:
    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:
    echo "s3cmd put $DEST s3://<your-space>/db/" >> /opt/hackerspace/backup-db.sh
    
  5. Lifecycle policy on the Space: rotate to cold storage / delete after N days.

13.3 Restoring

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

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="<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:

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

cd /opt/supabase/docker
git fetch origin && git pull origin master
docker compose pull
docker compose up -d

Read the release notes before pulling major versions. The Supabase docker-compose has occasional breaking env-var renames.

14.4 Logs

# 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

# 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 (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:
    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:

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(<pid>);. 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

sudo systemctl status certbot.timer
sudo journalctl -u certbot | tail -50

Most common cause: a hostname stopped resolving. Confirm dig +short <host> 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 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.