Partie 4 sur 7 — Supabase en auto-hébergement : retour d'expérience
Version française de Part 4 — The first Supabase instance.
Le serveur est prêt, Docker Swarm tourne, Traefik est en place. On passe maintenant au déploiement de Supabase. C'est la partie la plus riche en surprises. Je les documente au fil du récit.
Ce qu'est un projet Supabase
Avant d'écrire la moindre configuration, il est utile d'avoir une image claire de ce qu'on déploie. Un projet Supabase, c'est huit services Docker :
Internet
|
Traefik (terminaison TLS, routage)
|
Kong (API gateway, port 8000)
|
+-- GoTrue (auth, port 9999)
+-- PostgREST (REST API, port 3000)
+-- Realtime (WebSockets, port 4000)
+-- Storage (files, port 5000)
+-- Studio (dashboard, port 3000)
+-- postgres-meta (schema introspection, port 8080)
PostgreSQL (port 5432, internal only)
Kong est le seul service accessible depuis internet (via Traefik). Tous les autres résident sur un réseau overlay Docker interne. PostgreSQL n'est jamais publié sur l'hôte.
Les secrets à générer
Avant de rédiger le fichier compose, on génère ces valeurs :
# Postgres password
openssl rand -hex 16
# JWT secret (must be at least 32 characters)
openssl rand -hex 32
# For Studio's schema browser
openssl rand -hex 16 # PG_META_CRYPTO_KEY
Les clés anon et service_role sont des JWT standard signés avec votre secret JWT. Ce script les génère :
JWT_SECRET="your-jwt-secret-here"
# Expiry: year 2035 (Unix timestamp)
EXPIRY=2051222400
python3 - << EOF
import json, hmac, hashlib, base64
secret = "$JWT_SECRET"
def b64(data):
return base64.urlsafe_b64encode(data).rstrip(b'=').decode()
def enc(obj):
return b64(json.dumps(obj, separators=(',', ':')).encode())
header = enc({"alg":"HS256","typ":"JWT"})
for role in ["anon", "service_role"]:
payload = enc({
"role": role,
"iss": "supabase",
"iat": 1772393548,
"exp": $EXPIRY
})
msg = f"{header}.{payload}".encode()
sig = b64(hmac.new(secret.encode(), msg, hashlib.sha256).digest())
print(f"{role}: {header}.{payload}.{sig}")
EOF
Le JWT anon peut être exposé aux navigateurs sans risque. Le JWT service_role contourne la sécurité au niveau des lignes (RLS) et doit rester secret.
On stockera tout cela dans Vault à la partie 5. Pour l'instant, notez ces valeurs dans un endroit sûr.
Le docker-compose.yml
Voici la définition complète du stack. Je détaille chaque point inattendu dans les sections qui suivent.
Les tags d'images ci-dessous correspondent aux versions majeures actuelles. Pour les versions exactement épinglées de chaque composant, consultez la référence officielle d'auto-hébergement Supabase sur supabase.com/docs/guides/self-hosting. Supabase y maintient une combinaison de versions testée et stable.
version: '3.8'
networks:
internal:
driver: overlay
traefik_default:
external: true
name: traefik_default
volumes:
db_data:
storage_data:
services:
db:
image: supabase/postgres:15 # use latest 15.x from supabase.com/docs/guides/self-hosting
environment:
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
volumes:
- db_data:/var/lib/postgresql/data
networks:
- internal
deploy:
resources:
limits:
memory: 1g
cpus: '1.0'
healthcheck:
test: ["CMD-SHELL", "pg_isready -U postgres"]
interval: 30s
timeout: 10s
retries: 3
start_period: 30s
auth:
image: supabase/gotrue:latest # pin to a stable release tag; see note below
depends_on:
- db
environment:
GOTRUE_DB_DRIVER: postgres
GOTRUE_DB_DATABASE_URL: postgres://supabase_auth_admin:${POSTGRES_PASSWORD}@db:5432/postgres
GOTRUE_JWT_SECRET: ${JWT_SECRET}
GOTRUE_JWT_EXP: '3600'
GOTRUE_JWT_AUD: authenticated
GOTRUE_JWT_DEFAULT_GROUP_NAME: authenticated
GOTRUE_JWT_ADMIN_ROLES: service_role
GOTRUE_API_HOST: 0.0.0.0
GOTRUE_API_PORT: '9999'
GOTRUE_SITE_URL: ${SITE_URL}
GOTRUE_EXTERNAL_URL: ${GOTRUE_EXTERNAL_URL}
API_EXTERNAL_URL: ${API_EXTERNAL_URL}
GOTRUE_MAILER_AUTOCONFIRM: ${GOTRUE_MAILER_AUTOCONFIRM:-false}
GOTRUE_SMS_AUTOCONFIRM: 'false'
networks:
- internal
deploy:
resources:
limits:
memory: 256m
cpus: '0.5'
rest:
image: ghcr.io/supabase/postgrest:v12 # use latest stable v12
depends_on:
- db
environment:
PGRST_DB_URI: postgres://postgres:${POSTGRES_PASSWORD}@db:5432/postgres
PGRST_DB_SCHEMA: public,storage,graphql_public
PGRST_DB_ANON_ROLE: anon
PGRST_JWT_SECRET: ${JWT_SECRET}
PGRST_DB_USE_LEGACY_GUCS: 'false'
networks:
- internal
deploy:
resources:
limits:
memory: 256m
cpus: '0.5'
realtime:
image: ghcr.io/supabase/realtime:v2 # use latest stable v2
depends_on:
- db
environment:
DB_HOST: db
DB_PORT: 5432
DB_NAME: postgres
DB_USER: postgres
DB_PASSWORD: ${POSTGRES_PASSWORD}
DB_ENC_KEY: ${DB_ENC_KEY}
DB_AFTER_CONNECT_QUERY: SET search_path TO _realtime
API_JWT_SECRET: ${JWT_SECRET}
SECRET_KEY_BASE: ${SECRET_KEY_BASE}
APP_NAME: realtime
FLY_APP_NAME: realtime
FLY_ALLOC_ID: project1-realtime
PORT: '4000'
SEED_SELF_HOST: 'true'
RUN_JANITOR: 'true'
ENABLE_TAILSCALE: 'false'
DNS_NODES: ''
ERL_AFLAGS: -proto_dist inet_tcp
networks:
- internal
deploy:
resources:
limits:
memory: 512m
cpus: '0.5'
storage:
image: ghcr.io/supabase/storage-api:v1 # use latest stable v1
depends_on:
- db
environment:
ANON_KEY: ${SUPABASE_ANON_KEY}
SERVICE_KEY: ${SUPABASE_SERVICE_ROLE_KEY}
JWT_SECRET: ${JWT_SECRET}
DATABASE_URL: postgres://supabase_storage_admin:${POSTGRES_PASSWORD}@db:5432/postgres
FILE_STORAGE_BACKEND_PATH: /var/lib/storage
STORAGE_BACKEND: file
FILE_SIZE_LIMIT: '52428800'
GLOBAL_S3_BUCKET: stub
REGION: stub
TENANT_ID: stub
POSTGREST_URL: http://rest:3000
PGRST_JWT_SECRET: ${JWT_SECRET}
DB_INSTALL_ROLES: 'true'
volumes:
- storage_data:/var/lib/storage
networks:
- internal
deploy:
resources:
limits:
memory: 256m
cpus: '0.5'
kong:
image: ghcr.io/supabase/kong:2.8.1 # Kong version; only change if Supabase releases a new one
depends_on:
- db
environment:
KONG_DATABASE: 'off'
KONG_DECLARATIVE_CONFIG: /var/lib/kong/kong.yml
KONG_LOG_LEVEL: info
KONG_PROXY_ACCESS_LOG: /dev/stdout
KONG_PROXY_ERROR_LOG: /dev/stderr
KONG_ADMIN_ACCESS_LOG: /dev/stdout
KONG_ADMIN_ERROR_LOG: /dev/stderr
KONG_SERVER_TOKENS: 'off'
volumes:
- /root/supabase-vps-cluster/instances/project1/kong.yml:/var/lib/kong/kong.yml:ro
networks:
- internal
- traefik_default
deploy:
resources:
limits:
memory: 512m
cpus: '0.5'
labels:
traefik.enable: 'true'
traefik.http.routers.p1-kong.entrypoints: websecure
traefik.http.routers.p1-kong.rule: Host(`kong.project1.yourdomain.com`)
traefik.http.routers.p1-kong.tls.certresolver: le
traefik.http.routers.p1-kong.middlewares: security-headers@swarm
traefik.http.services.p1-kong.loadbalancer.server.port: '8000'
traefik.swarm.network: traefik_default
meta:
image: supabase/postgres-meta:v0 # use latest stable v0
depends_on:
- db
environment:
PG_META_PORT: 8080
PG_META_DB_HOST: db
PG_META_DB_PORT: 5432
PG_META_DB_NAME: postgres
PG_META_DB_USER: supabase_admin
PG_META_DB_PASSWORD: ${POSTGRES_PASSWORD}
PG_META_DB_SSL_MODE: disable
PG_META_CRYPTO_KEY: ${PG_META_CRYPTO_KEY}
healthcheck:
disable: true
networks:
- internal
deploy:
resources:
limits:
memory: 256m
cpus: '0.25'
studio:
image: supabase/studio:latest # always use the latest Studio tag
depends_on:
- db
environment:
HOSTNAME: 0.0.0.0
SUPABASE_URL: http://kong:8000
SUPABASE_PUBLIC_URL: ${API_EXTERNAL_URL}
SUPABASE_ANON_KEY: ${SUPABASE_ANON_KEY}
SUPABASE_SERVICE_KEY: ${SUPABASE_SERVICE_ROLE_KEY}
AUTH_JWT_SECRET: ${JWT_SECRET}
STUDIO_PG_META_URL: http://meta:8080
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
DEFAULT_ORGANIZATION_NAME: Default Organization
DEFAULT_PROJECT_NAME: Default Project
healthcheck:
disable: true
networks:
- internal
- traefik_default
deploy:
resources:
limits:
memory: 512m
cpus: '0.5'
labels:
traefik.enable: 'true'
traefik.http.routers.p1-studio.entrypoints: websecure
traefik.http.routers.p1-studio.rule: Host(`studio.project1.yourdomain.com`)
traefik.http.routers.p1-studio.tls.certresolver: le
traefik.http.services.p1-studio.loadbalancer.server.port: '3000'
traefik.swarm.network: traefik_default
traefik.http.routers.p1-studio.middlewares: security-headers@swarm,p1-studio-auth@swarm
traefik.http.middlewares.p1-studio-auth.basicauth.users: YOUR_HASHED_CREDENTIALS
Remplacez YOUR_HASHED_CREDENTIALS par un hash bcrypt de votre mot de passe. Installez l'outil et générez le hash directement sur le serveur :
apt install apache2-utils -y
htpasswd -nB admin
# New password:
# Re-type new password:
# admin:$2y$05$...
Copiez le résultat, nom d'utilisateur inclus. Dans les labels Docker Compose, chaque $ doit être doublé, car Compose utilise $ pour l'interpolation de variables. La chaîne admin:$2y$05$... devient donc admin:$$2y$$05$$... dans le label.
kong.yml : la configuration de l'API gateway
Le fichier compose monte en bind /root/supabase-vps-cluster/instances/project1/kong.yml dans le conteneur Kong. C'est dans ce fichier que l'on définit les routes, l'authentification et la limitation de débit. Il n'est pas versionné dans git, car il contient vos clés API.
Créez-le à cet emplacement sur le serveur :
_format_version: '2.1'
_transform: true
consumers:
- username: anon
keyauth_credentials:
- key: YOUR_SUPABASE_ANON_KEY
- username: service_role
keyauth_credentials:
- key: YOUR_SUPABASE_SERVICE_ROLE_KEY
acls:
- consumer: anon
group: anon
- consumer: service_role
group: admin
services:
- name: auth-v1-open
url: http://auth:9999/verify
routes:
- name: auth-v1-open
strip_path: true
paths:
- /auth/v1/verify
plugins:
- name: cors
- name: auth-v1-open-callback
url: http://auth:9999/callback
routes:
- name: auth-v1-open-callback
strip_path: true
paths:
- /auth/v1/callback
plugins:
- name: cors
- name: auth-v1
url: http://auth:9999/
routes:
- name: auth-v1-all
strip_path: true
paths:
- /auth/v1/
plugins:
- name: cors
- name: key-auth
config:
hide_credentials: false
- name: acl
config:
hide_groups_header: true
allow:
- admin
- anon
- name: rate-limiting
config:
minute: 30
policy: local
limit_by: ip
- name: rest-v1
url: http://rest:3000/
routes:
- name: rest-v1-all
strip_path: true
paths:
- /rest/v1/
plugins:
- name: cors
- name: key-auth
config:
hide_credentials: true
- name: acl
config:
hide_groups_header: true
allow:
- admin
- anon
- name: realtime-v1-ws
url: http://realtime:4000/socket
protocol: ws
routes:
- name: realtime-v1-ws
strip_path: true
paths:
- /realtime/v1/
plugins:
- name: cors
- name: key-auth
config:
hide_credentials: false
- name: acl
config:
hide_groups_header: true
allow:
- admin
- anon
- name: storage-v1
url: http://storage:5000/
routes:
- name: storage-v1-all
strip_path: true
paths:
- /storage/v1/
plugins:
- name: cors
- name: key-auth
config:
hide_credentials: true
- name: acl
config:
hide_groups_header: true
allow:
- admin
- anon
Quelques points à noter. Les routes auth-v1-open (/verify, /callback) sont intentionnellement laissées sans key-auth : ce sont les endpoints de redirection OAuth que les navigateurs appellent directement lors des flux de connexion et qui ne peuvent pas inclure un en-tête de clé API. Tout le reste exige une clé valide.
Les permissions du fichier ont leur importance : chmod 644 kong.yml. Kong tourne en tant qu'utilisateur non-root et échoue avec une erreur de permission si le fichier est en 600 ou 700.
Après toute modification de ce fichier, Kong ne la prend pas en compte automatiquement. Un redémarrage forcé s'impose :
docker service update --force project1_kong
Point inattendu 1 : les limites mémoire ne sont pas optionnelles
Sans limites mémoire, les services se disputent la RAM sur un serveur de 4 Go et peuvent déclencher des arrêts OOM (Out of Memory : le noyau Linux interrompt le processus le plus gourmand quand la mémoire est épuisée) qui emportent d'autres conteneurs. Des limites strictes sont indispensables.
Les valeurs auxquelles je suis arrivé après ajustement :
| Service | Limite mémoire | Raison |
|---|---|---|
| db | 1 Go | Cache de buffers Postgres |
| kong | 512 Mo | Plus que prévu, Kong met la config en cache |
| realtime | 512 Mo | La VM Erlang/BEAM consomme ~200 Mo au repos |
| studio | 512 Mo | Rendu côté serveur Next.js |
| auth | 256 Mo | GoTrue |
| rest | 256 Mo | PostgREST |
| storage | 256 Mo | API Storage |
| meta | 256 Mo | postgres-meta |
Realtime a été la vraie surprise. Le runtime Erlang/BEAM a une empreinte mémoire de base élevée, autour de 200 Mo avant qu'aucune connexion ne soit établie. J'avais initialement fixé la limite à 256 Mo, ce qui paraissait généreux, et le service continuait d'atteindre cette limite. 512 Mo est la valeur correcte. C'est ce que Supabase Cloud alloue, pour la même raison.
Point inattendu 2 : Studio a besoin de trois variables non évidentes
Studio est une application Next.js. Le rendu côté serveur s'exécute dans le conteneur ; le rendu côté client s'exécute dans le navigateur. Ces deux contextes ont besoin d'URL différentes :
-
SUPABASE_URL: http://kong:8000: pour le code côté serveur qui s'exécute dans Docker, il atteint Kong par nom de conteneur sur le réseau interne. -
SUPABASE_PUBLIC_URL: l'URL HTTPS publique, pour le code côté navigateur. -
POSTGRES_PASSWORD: Studio effectue des connexions Postgres directes pour son éditeur de requêtes.
Si l'une de ces variables est absente, Studio produit des erreurs 400/500 déconcertantes dans la console du navigateur, sans indication claire de la cause. J'ai dû lire le code source de Studio pour comprendre pourquoi. Ce n'est pas évident pour l'utilisateur.
Point inattendu 3 : la vérification d'état de Studio le tue
L'image supabase/studio embarque une vérification d'état Docker intégrée. Dans Swarm, un conteneur qui échoue à sa vérification d'état est tué et redémarré. La vérification d'état de Studio échouait dans notre configuration.
La solution : la désactiver.
healthcheck:
disable: true
Même problème avec postgres-meta. Il embarque lui aussi une vérification d'état intégrée qui provoque un exit 137 (SIGKILL) dans Swarm. Il faut la désactiver également.
Point inattendu 4 : on ne peut pas figer GOTRUE_MAILER_AUTOCONFIRM en dur
Pour le développement et les tests de charge, on veut que l'inscription par e-mail soit confirmée automatiquement (sans e-mail de vérification). J'avais initialement défini cette valeur en dur dans le fichier compose :
GOTRUE_MAILER_AUTOCONFIRM: 'false'
Puis j'ai eu besoin de passer à true. J'ai mis à jour le fichier .env et redéployé. Rien n'a changé. Le service continuait de lire false.
Le problème vient du fait qu'une valeur fixée en dur dans le bloc environment: est prioritaire sur une variable provenant du fichier .env. La variable du .env était tout simplement ignorée.
La correction : utiliser la substitution de variable.
GOTRUE_MAILER_AUTOCONFIRM: ${GOTRUE_MAILER_AUTOCONFIRM:-false}
La partie :-false signifie "utiliser cette valeur si la variable n'est pas définie". Désormais, c'est le fichier .env qui contrôle la valeur. C'est ainsi que cela aurait dû être dès le départ.
Point inattendu 5 : DB_ENC_KEY doit faire exactement 16 octets
Realtime utilise le chiffrement AES-128-ECB. AES-128 exige une clé de 16 octets. J'ai généré une clé avec openssl rand -hex 32, ce qui donne 32 caractères hexadécimaux. Or 32 caractères hex représentent 16 octets, à raison de 2 caractères par octet. Ça devrait fonctionner, non ?
Non. Realtime passe la chaîne de clé directement comme valeur de clé, sans la traiter comme un tableau d'octets encodé en hexadécimal. La commande openssl rand -hex 32 produit une chaîne de 32 caractères, qui est interprétée comme 32 octets. AES-128 n'en accepte que 16. Le service plante avec l'erreur "Bad key size".
La valeur par défaut officielle pour Realtime en auto-hébergement est la chaîne littérale supabaserealtime. Elle fait exactement 16 caractères, donc 16 octets. Utilisez cette valeur. N'essayez pas d'être créatif avec la génération de clé ici.
Point inattendu 6 : le schéma _realtime
Le dépôt Docker Compose officiel de Supabase inclut un fichier docker/volumes/db/realtime.sql monté dans le conteneur Postgres, qui crée le schéma _realtime automatiquement au premier démarrage. Si vous clonez le dépôt officiel, c'est pris en charge.
Cette série construit un fichier compose de zéro. Ce montage n'est pas présent, donc _realtime n'est jamais créé. Realtime v2.76+ en a besoin pour sa configuration multi-tenant et plante au démarrage sans indiquer clairement ce qui manque.
Exécutez ceci une seule fois après le premier déploiement :
docker exec $(docker ps --filter name=project1_db --format '{{.Names}}' | head -1) \
psql -U postgres -d postgres \
-c "CREATE SCHEMA IF NOT EXISTS _realtime;"
docker service update --force project1_realtime
Ce que fait le script : il crée le schéma _realtime, renomme le tenant que SEED_SELF_HOST crée de realtime-dev en realtime (un décalage de nommage entre la logique de seed et le nom de l'application), puis force le redémarrage du service. Il est idempotent.
Point inattendu 7 : API_EXTERNAL_URL doit pointer vers Kong
API_EXTERNAL_URL détermine les URL que GoTrue insère dans les e-mails (réinitialisations de mot de passe, confirmations) ainsi que l'URL publique qu'utilise Studio pour les appels API côté navigateur.
J'avais pointé cette variable vers PostgREST, puisque PostgREST est l'API REST. Cela semblait logique. Mais PostgREST est un service interne. Kong est la passerelle qui expose tout, gère l'authentification et applique la limitation de débit. L'URL externe doit être l'adresse publique de Kong :
API_EXTERNAL_URL=https://kong.project1.yourdomain.com
Pointer vers PostgREST contourne Kong entièrement, ce qui casse l'authentification.
À propos des tags d'image GoTrue
Déploiement
On crée le fichier .env (on le migrera dans Vault à la partie 5). D'abord, les deux secrets restants à générer — ce sont des commandes shell, pas des valeurs .env littérales :
openssl rand -hex 64 # copy this as SECRET_KEY_BASE
openssl rand -hex 16 # copy this as PG_META_CRYPTO_KEY
Ensuite, on crée le fichier avec les vraies valeurs :
# instances/project1/.env
POSTGRES_PASSWORD=<generated above>
JWT_SECRET=<generated above>
SUPABASE_ANON_KEY=<anon jwt from the script>
SUPABASE_SERVICE_ROLE_KEY=<service_role jwt from the script>
API_EXTERNAL_URL=https://kong.project1.yourdomain.com
GOTRUE_EXTERNAL_URL=https://kong.project1.yourdomain.com
SITE_URL=https://kong.project1.yourdomain.com
GOTRUE_MAILER_AUTOCONFIRM=false
DB_ENC_KEY=supabaserealtime
SECRET_KEY_BASE=<paste the 128-char hex string>
PG_META_CRYPTO_KEY=<paste the 32-char hex string>
Déploiement :
set -a && source instances/project1/.env && set +a
docker stack deploy -c instances/project1/docker-compose.yml project1
Vérification que tous les services démarrent :
docker service ls | grep project1
Les huit services doivent afficher 1/1 replica en l'espace d'une à deux minutes. Si l'un affiche 0/1, consultez les logs :
docker service logs --tail 50 project1_auth
Initialisation de Realtime :
bash scripts/init-realtime.sh project1
Test de l'endpoint API :
curl -s https://kong.project1.yourdomain.com/health
# {"status":"healthy"}
Où en sommes-nous
Une instance Supabase fonctionnelle : Postgres, authentification, API REST, abonnements temps réel, stockage de fichiers, et un tableau de bord protégé par une authentification de base.
Dans le prochain article, on migre tous ces secrets hors des fichiers plats et dans Vault. Et je vous raconte l'après-midi où j'ai accidentellement tout supprimé.
La série complète
- Pourquoi auto-héberger
- Le serveur
- Traefik et SSL
- La première instance Supabase, vous êtes ici
- Vault
- Deux instances
- Sécurité et test de charge
Top comments (0)