supabase-selfhost-ops

mcp
Security Audit
Fail
Health Pass
  • License — License: MIT
  • Description — Repository has a description
  • Active repo — Last push 0 days ago
  • Community trust — 15 GitHub stars
Code Fail
  • rm -rf — Recursive force deletion command in migrate.sh
Permissions Pass
  • Permissions — No dangerous permissions requested

No AI report is available for this listing yet.

SUMMARY

Self-host Supabase in production, AI-ready: read-only MCP for your coding agent with zero secret exposure, SSO, PITR backups, monitoring, disk encryption, and one-command migration off Supabase Cloud.

README.md

Ansible Supabase

One-command deployment of a self-hosted, production-ready Supabase stack on Debian, Ubuntu, or Arch Linux. The playbook installs Docker, clones the latest Supabase release, generates all configuration files, and starts the full stack — secured by default with automatic TLS, SSO/OAuth2, basic auth, IP allow-listing, a firewall, and brute-force protection.

This repository's purpose is to give you a ready-to-use, full-featured Supabase with security, encryption, and SSO/auth baked in — not a bare dashboard exposed to the internet.

Encryption at rest (LUKS) and automated backups + PITR (pgBackRest) are included and ready to enable; they need a dedicated disk volume and (for off-box backups) S3 credentials respectively, so they are shown as optional hardening steps at the end of this guide.

For deep customization (custom OAuth providers, Grafana modes, retention tuning, version pinning) see docs/advanced-docs.md.

Server support matrix

All Tier 1 targets — full stack CI on every PR:

Target Notes
Ubuntu 24.04, Ubuntu 22.04 the default assumption in this README
Debian 12
Arch rolling, tracks upstream Docker

Out of scope (stated here so nobody discovers it at run time): RHEL family (SELinux needs its own work) and Manjaro as a server target (it's a desktop distro — it appears in this repo only on the client side, in docs/connect-your-agent.md).

The role detects the distro family from ID_LIKE in /etc/os-release (never ID — Mint and Pop!_OS report ID_LIKE=ubuntu debian, which buys most derivatives for free).


📑 Table of Contents


🚀 Quick Start (recommended)

The deterministic installer (setup.sh) reads a single config.yml file, generates all cryptographic secrets, renders the Ansible variables, enables the components you want, and deploys. This is the easiest path for both humans and AI agents.

1. 📋 Prerequisites

  • A Debian 12, Ubuntu 22.04/24.04, or Arch Linux server with root or sudo access (see the server support matrix above)
  • A domain with three DNS A records pointing to your server:
    • sb.example.com — Supabase dashboard + API
    • auth.example.com — OAuth2 authentication endpoint
    • monitor.example.com — Grafana dashboard (monitoring is enabled by default)
  • Ports 80 and 443 reachable for automatic Let's Encrypt TLS and the Caddy reverse proxy
  • A registered OAuth2 application (GitHub, GitLab, Discord, or any OIDC provider) to protect the dashboard via SSO — see SSO Provider Setup below

2. 📥 Clone

git clone https://github.com/ankaboot-source/ansible-supabase.git
cd ansible-supabase

3. ⚙️ Create config.yml and fill the REQUIRED section

cp config.example.yml config.yml
$EDITOR config.yml   # or: code config.yml

config.yml has four clearly separated sections. Only the required block must be filled in — everything else has safe defaults:

# ─── REQUIRED (you must fill these) ───────────────────────────
required:
  deploy_user: your-ssh-username
  site_url: https://app.example.com          # your app's public URL
  api_external_url: https://sb.example.com   # Supabase API endpoint
  supabase_domain: sb.example.com            # dashboard domain
  smtp_admin_email: [email protected]
  smtp_host: mail.example.com
  smtp_user: [email protected]
  smtp_password: <smtp-password>

# ─── SECRETS (auto-generated by default) ─────────────────────
secrets:
  generate: true          # set false to provide your own keys below
  # postgres_db_pwd: ...
  # sb_jwt_secret: ...

# ─── COMPONENTS (all off by default — enable what you need) ──
components:
  caddy: false            # reverse proxy + TLS + SSO
  monitor: false          # Grafana + Prometheus + Loki
  fail2ban: false        # brute-force protection
  backup: false           # Automated backups + PITR (pgBackRest)
  ufw: false              # firewall
  luks: false             # at-rest disk encryption

# ─── ADVANCED (only needed when a component is enabled) ──────
advanced:
  caddy:
    sso_provider: Generic   # github | gitlab | discord | Generic
    # ...

4. ▶️ Deploy

sudo bash setup.sh

That's it. setup.sh will:

  1. Validate the REQUIRED fields are filled (fails fast with a clear list if not).
  2. Auto-generate all cryptographic secrets (JWT, anon/service keys, Postgres password, etc.) unless secrets.generate: false.
  3. Render env/supabase.yml from your config.
  4. Enable the selected components in playbook-supabase.yml.
  5. Run the Ansible deployment via install.sh.

Useful flags

Flag Description
--dry-run Preview what would happen without modifying any files
--yes Non-interactive (skip confirmation prompts) — ideal for CI/AI agents
--force Regenerate secrets even if env/supabase.yml is already locked
-v, --verbose Verbose output
-h, --help Show help

Locking: after the first successful render, setup.sh writes env/.setup.lock. Subsequent runs preserve the existing secrets in env/supabase.yml — they are neither regenerated nor overwritten with placeholders — so already-running Supabase services keep working. Pass --force to regenerate secrets on purpose (e.g. after a key rotation). --dry-run never writes the lock.

# Preview without changes
bash setup.sh --dry-run

# Fully non-interactive (AI/CI)
sudo bash setup.sh --yes

⚠️ Security Notice: With all components disabled the Supabase dashboard is exposed without authentication. For production, enable caddy (SSO/basic auth), ufw, and fail2ban in config.yml — see docs/advanced-docs.md.


🔧 Advanced: manual install.sh flow

If you prefer full control over env/supabase.yml and playbook-supabase.yml directly (or are upgrading from a previous setup), the original manual flow still works:

1. Generate Supabase required keys

sh generate-keys.sh

This updates env/supabase.yml with all Supabase cryptographic secrets (JWT, anon key, service role key, Postgres password, and all tokens).

2. Configure env/supabase.yml

Open env/supabase.yml and fill in every field tagged #REQUIRED. The file ships with secure defaults (basic auth + IP allow-list + SSO on the dashboard). Keep them — do not strip them down.

# ── System User ──────────────────────────────────
deploy_user: your-ssh-username
docker_users:
  - your-ssh-username

# ── Supabase Secrets (auto-generated in step 3) ──────
postgres_db_pwd: <strong-password>
sb_jwt_secret: <jwt-secret-from-generator>
sb_anon_key: <anon-key-from-generator>
sb_service_role_key: <service-role-key-from-generator>
secret_key_base: ...
vault_enc_key: ...
pg_meta_crypto_key: ...
logflare_public_access_token: ...
logflare_private_access_token: ...
s3_protocol_access_key_id: ...
s3_protocol_access_key_secret: ...
pooler_tenant_id: pooler

# ── Public URLs ──────────────────────────────────
site_url: https://app.example.com          # Your app's public URL
api_external_url: https://sb.example.com   # Supabase API endpoint (used by Studio)
additional_redirect_urls: https://app.example.com/auth/callback
mailer_templates_base_url: https://app.example.com

# ── SMTP (for auth emails) ───────────────────────
smtp_admin_email: [email protected]
smtp_host: mail.example.com
smtp_user: [email protected]
smtp_password: <smtp-password>

SSO Provider Setup

Pick one OAuth2 provider and fill in its block in env/supabase.yml. Set SSO_PROVIDER to github, gitlab, discord, or generic (any OIDC).

GitHub (redirect URI: https://sb.example.com/oauth2/github/authorization-code-callback):

SSO_PROVIDER: github
github_oauth_client_id: <your-client-id>
github_oauth_client_secret: <your-client-secret>
github_allow_list: "github.com/user1 github.com/user2"

GitLab (redirect URI: https://sb.example.com/oauth2/gitlab/authorization-code-callback):

SSO_PROVIDER: gitlab
gitlab_domain: gitlab.com
gitlab_oauth_client_id: <your-client-id>
gitlab_oauth_client_secret: <your-client-secret>
gitlab_allow_list: "[email protected] [email protected]"

Discord (redirect URI: https://sb.example.com/oauth2/discord/authorization-code-callback):

SSO_PROVIDER: discord
discord_oauth_client_id: <your-client-id>
discord_oauth_client_secret: <your-client-secret>
admin_role_id: <your-admin-user-id>
discord_guild_id: <your-discord-server-id>

Generic OIDC (any OpenID Connect provider, e.g. Keycloak):

SSO_PROVIDER: generic
oidc_realm: generic
oidc_driver: generic
oidc_client_id: <your-client-id>
oidc_client_secret: <your-client-secret>
base_auth_url: https://keycloak.example.com
metadata_url: https://keycloak.example.com/.well-known/openid-configuration
app_url: https://sb.example.com
generic_allow_list: "[email protected] [email protected]"

Common SSO variables (required for any provider):

base_auth_domain: auth.example.com    # OAuth2 auth endpoint subdomain
root_domain: example.com              # root domain for SSO cookies
jwt_shared_key: <openssl rand -base64 32>

🛡️ Caddyfile Configuration (Reverse Proxy + SSO + Basic Auth)

The projects block in env/supabase.yml is pre-configured to protect the dashboard with SSO, basic auth, and an IP allow-list. Keep this secure default:

projects:
  supabase:
    log_file: supabase-access
    domain: "sb.example.com"
    allowed_ips:                       # IP allow-list — remove if you don't need it
      - 123.123.123.123
      - 111.111.111.111
    oidc_enabled: true
    upstreams:
      # Dashboard — protected by SSO + basic auth
      - targets: ["localhost:3001"]
        paths: [""]
        oidc: true
        basicauth:
          - path: /project/default
            username: your_user
            # Generate with: caddy hash-password
            password: $2a$10$...
      # API routes — Kong handles auth, no SSO
      - targets: ["localhost:8000"]
        paths:
          - /rest/v1/*
          - /auth/v1/*
          - /realtime/v1/*
          - /storage/v1/*
          - /functions/v1/*
        oidc: false

  monitor:
    log_file: monitor-access
    domain: "monitor.example.com"
    oidc_enabled: false
    upstreams:
      - targets: ["localhost:3002"]
        paths: [""]
        oidc: false

To lock down Grafana, set GRAFANA_AUTH_ANONYMOUS_ENABLED: false and enable basic auth or GitHub OAuth for Grafana (see docs/advanced-docs.md).

🧱 Firewall & Brute-force Protection

The default firewall_allow / firewall_deny and fail2ban blocks in env/supabase.yml are already sane (allow 80/443 + SSH, deny internal ports). Adjust the allowed_ips and firewall_allow entries to your needs.

5. 🧩 Enable the Security Roles

The security roles ship commented in playbook-supabase.yml. Uncomment them so the default deploy includes the full security stack:

---
- hosts: localhost
  become: true
  roles:
   - docker
   - supabase

   # ─── Security & monitoring (enabled by default) ───
   - ufw                     # Firewall — allow/deny rules per port
   - caddy                   # Reverse proxy + automatic TLS + SSO + basic auth
   - fail2ban                # Brute-force protection for Postgres
   - monitor                 # Grafana + Prometheus + Loki stack

   # ─── Optional hardening (need external resources) ───
   # - role: luks             # At-rest disk encryption (needs a dedicated volume)
   #   when: supabase_encryption.enabled
    # - backup                 # Automated backups + PITR (pgBackRest)

6. ▶️ Deploy

Run the installer (installs Ansible + Git if needed, then executes the playbook):

sudo ./install.sh

To see what will happen without making changes:

sudo ./install.sh -d

🔒 Optional Hardening

These two features are part of the complete stack but require external resources, so they are not enabled by default. Enable them for a fully hardened deployment.

At-rest Disk Encryption (LUKS)

Encrypts a separate data volume for Postgres data with automatic unlock on boot. Set in env/supabase.yml:

supabase_encryption:
  enabled: true
luks_device: /dev/disk/by-id/YOUR_VOLUME_NAME
luks_mount_point: /data

Then uncomment the luks role in playbook-supabase.yml (see step 5).

Automated Backups + PITR (pgBackRest)

Runs pgBackRest inside the supabase-db container (the upstream supabase/postgres image is not forked; the pgbackrest binary + libs are bind-mounted in). Provides continuous WAL archiving, scheduled full + differential backups, point-in-time recovery, and repo-integrity verification. Defaults to a local MinIO repo (no off-box protection — a loud warning is printed); switch to an external S3 bucket for real protection. When enabled, the installer brings up MinIO before Supabase so WAL archiving resolves on the db's first boot. Set in config.yml:

components:
  backup: true
advanced:
  backup:
    repo_type: s3              # minio (local, default) | s3 (external) | posix (local fs)
    s3_endpoint: https://<your-endpoint>   # host-only (no URL path); e.g. https://s3.eu-west-1.amazonaws.com
    s3_region: us-east-1
    s3_bucket: supabase-backups
    s3_access_key: <your-key>
    s3_secret_key: <your-secret>
    # Encryption is forced ON for external S3 repos.
    # Credentials default to a plaintext .env file; set creds_source: vault
    # to load them from Ansible Vault instead.

Then run ./setup.sh (it regenerates playbook-supabase.yml from the toggles — no manual uncommenting needed). See docs/advanced-docs.md for the restore-during-an-incident runbook, retention tuning, and the full hardening checklist.


📦 What Gets Deployed

Container Service Port
studio Supabase Dashboard 3001
kong API Gateway 8000
auth GoTrue (Authentication) 9999
rest PostgREST (REST API) 3000
realtime Realtime (WebSockets) 4000
storage Storage API 5000
imgproxy Image Transformation 5001
meta postgres-meta 8080
functions Edge Functions (Deno) 9000
db PostgreSQL 17 5432
supavisor Connection Pooler 6543

Plus the security/monitoring stack: Caddy (reverse proxy + TLS + SSO), UFW firewall, Fail2ban, and Grafana/Prometheus/Loki.

Every deployment also writes:

  • /etc/supabase/instance.json — the instance manifest (JSON contract: ports, container names, endpoints, secret locations). No secret values, ever.
  • /usr/local/bin/supabase-agent — an MCP server over SSH stdio for AI agents (read-only tools, no secret values).
  • /usr/local/bin/supabase-selfhosted — a CLI to read the manifest and resolve secrets (TTY-aware redaction).

📚 Advanced Features

Feature Description
Caddy Reverse Proxy + SSO Automatic TLS, GitHub/GitLab/Discord/Generic OIDC, basic auth, IP allow lists
Monitoring Stack Grafana, Prometheus, Loki, Node Exporter, cAdvisor, Postgres Exporter
LUKS Encryption At-rest disk encryption for Postgres data
Backups + PITR pgBackRest: continuous WAL archiving, scheduled full/diff backups, point-in-time recovery, repo verification
Fail2ban Brute-force protection for PostgreSQL
UFW Firewall Fine-grained allow/deny rules
Secure MCP Access MCP server restricted to localhost; authorized clients connect via SSH tunnel — no public exposure
Instance Manifest /etc/supabase/instance.json — a JSON contract describing the instance (ports, containers, endpoints, secret locations)
SSH-stdio Agent /usr/local/bin/supabase-agent — MCP over SSH stdio for AI agents; read-only tools, no secret values
Info CLI supabase-selfhosted info — reads the manifest, resolves secrets with TTY-aware redaction

Full documentation: docs/advanced-docs.md


🤖 Connect Your AI Agent

Every deployment writes an instance manifest to /etc/supabase/instance.json — a JSON contract describing the instance (database ports, container names, endpoints, secret locations — never secret values). It also generates an ed25519 SSH key restricted to running /usr/local/bin/supabase-agent, an MCP server over stdio that exposes read-only tools (no secret values ever).

At the end of a fresh deployment, the playbook prints the private key once, plus a ready-to-paste ~/.ssh/config block and an MCP client config snippet. To connect your agent (Claude Code, Codex, opencode, pi — they all take the same {command, args} shape), follow the one-page guide:

docs/connect-your-agent.md

Then verify the connection with:

sh scripts/verify-connection.sh ~/.ssh/supabase-agent-<host> supabase-agent

The verify script is POSIX-clean and runs on both GNU and BSD userland (Linux + macOS).

Reading secrets back

supabase-selfhosted info reads the manifest and resolves the secret references against the .env file. The default flips on whether stdout is a TTY: a human at a terminal gets the real values (the "what was my service_role key again" recovery path); a pipe, file, or captured stream gets •••• plus the reference.

# On the server, at a terminal — shows real values:
supabase-selfhosted info

# Piped (redacted):
supabase-selfhosted info | cat

# Force reveal (for `| pbcopy`):
supabase-selfhosted info --show-secrets | pbcopy

# Force redaction (for screen sharing):
supabase-selfhosted info --no-secrets

# JSON output (redacted unless --show-secrets):
supabase-selfhosted info --json

The MCP agent connects with no-pty, so it never sees a value it didn't explicitly ask for — and no MCP tool returns secret values.

v2 (deferred)

This is v1: the playbook writes nothing on your machine — it prints a snippet, and the docs explain where to put it. v2 will automate the client side: generate the key into ~/.ssh, write the Host block behind managed markers, and update agent config files (backed up first). It's the difference between "paste one block" and "nothing to paste", and it isn't worth blocking the server work on.


The Supabase MCP server is exposed at /mcp through the Kong gateway (routed to
Studio's /api/mcp). It is never publicly reachable — Kong's
ip-restriction allow list defaults to the Docker bridge gateway
(172.28.0.1; Docker source-NATs host connections to that gateway), so only
host-originated traffic can reach it. Caddy never reverse-proxies /mcp, and
the direct /api/mcp path stays blocked (403).

Authorized clients connect through an SSH tunnel, reusing the existing SSH
access (port 22) — no new public ports or subdomains:

ssh -L 8080:localhost:8000 [email protected] -N

Then point your MCP client at:

http://localhost:8080/mcp
  • The allow list is configurable via mcp_allowed_ips in env/supabase.yml.
    Add a private VPN subnet (e.g. 10.0.0.0/24) to allow it in addition, or set
    mcp_allowed_ips: [] to fully disable /mcp.
  • Warning: adding a public IP or 0.0.0.0/0 re-exposes the endpoint to the
    Internet — don't.

Full details: docs/advanced-docs.md → "Secure MCP Remote Access"


🚚 Migration from Supabase Cloud

Once your self-hosted stack is running, you can migrate an existing Supabase
Cloud project into it with a single command. The migrate.sh script is a
Layer 1 walking skeleton: it migrates schema + data, auth users (UUIDs
preserved), and storage objects, then prints a checklist of the manual steps
that remain.

What migrates automatically

  • Database schema + datapg_dump/pg_restore across Supabase-managed
    schemas (public, auth, storage, _realtime, graphql_public,
    extensions, pgsodium). Missing schemas are skipped with a warning.
  • Auth usersauth.users and auth.identities migrated with UUIDs
    preserved. Password hashes migrate, so existing passwords still work; users
    must log in again (sessions are not migrated).
  • Storage objects — copied via rclone from the Cloud S3 endpoint to your
    self-hosted storage (read-only against the source).

What stays manual (printed at the end)

Auth configuration, Edge Functions, cron jobs, webhooks, storage bucket
configuration, and client env-var updates. The script prints a fixed checklist
at the end — migration is incomplete but never silently incomplete.

Usage

# 1. Copy the example config and edit it
cp env/migrate.example.yml env/migrate.yml
# Edit env/migrate.yml: fill in the SOURCE (Cloud) and TARGET (self-hosted) sections

# 2. Preview the migration plan (no changes made)
./migrate.sh --config env/migrate.yml --dry-run

# 3. Run the migration (non-interactive, for CI/automation)
./migrate.sh --config env/migrate.yml --yes

Invariants

  • Read-only against the source. Always. The script uses pg_dump
    (inherently read-only) and rclone copy (not sync/move), and refuses to
    run if source.db_url == target.db_url.
  • Refuses a non-empty target. Layer 1 migrates into a fresh instance only.
  • No resumability. A failure means starting over.
  • Runs with no TTY. --yes gates all prompts; colors auto-disable.

See docs/designs/migration-layer-1.md for
the full design and docs/test-cases/migration-layer-1.md
for the test matrix.


📄 License

MIT

Reviews (0)

No results found