supabase-selfhost-ops
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.
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.
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)
- 🔧 Advanced: manual
install.shflow - 🔒 Optional Hardening
- 📦 What Gets Deployed
- 📚 Advanced Features
- 🤖 Connect Your AI Agent
- 🔒 Secure MCP Remote Access
- 🚚 Migration from Supabase Cloud
- 📄 License
🚀 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 + APIauth.example.com— OAuth2 authentication endpointmonitor.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:
- Validate the REQUIRED fields are filled (fails fast with a clear list if not).
- Auto-generate all cryptographic secrets (JWT, anon/service keys, Postgres password, etc.) unless
secrets.generate: false. - Render
env/supabase.ymlfrom your config. - Enable the selected components in
playbook-supabase.yml. - 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.shwritesenv/.setup.lock. Subsequent runs preserve the existing secrets inenv/supabase.yml— they are neither regenerated nor overwritten with placeholders — so already-running Supabase services keep working. Pass--forceto regenerate secrets on purpose (e.g. after a key rotation).--dry-runnever 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, andfail2baninconfig.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: falseand 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:
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'sip-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_ipsinenv/supabase.yml.
Add a private VPN subnet (e.g.10.0.0.0/24) to allow it in addition, or setmcp_allowed_ips: []to fully disable/mcp. - Warning: adding a public IP or
0.0.0.0/0re-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 + data —
pg_dump/pg_restoreacross Supabase-managed
schemas (public,auth,storage,_realtime,graphql_public,extensions,pgsodium). Missing schemas are skipped with a warning. - Auth users —
auth.usersandauth.identitiesmigrated with UUIDs
preserved. Password hashes migrate, so existing passwords still work; users
must log in again (sessions are not migrated). - Storage objects — copied via
rclonefrom 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) andrclone copy(notsync/move), and refuses to
run ifsource.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.
--yesgates 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)
Sign in to leave a review.
Leave a reviewNo results found