{ config, lib, pkgs, ... }: let kongEntrypoint = pkgs.writeTextFile { name = "kong-entrypoint.sh"; executable = true; text = builtins.readFile ./kong-entrypoint.sh; }; envGenerateScript = pkgs.writeShellScript "supabase-env-generate" (builtins.readFile ./env-generate.sh); # Common extra options for all containers to join the supabase network supabaseNet = [ "--network=supabase-net" ]; in { # --- SOPS secret --- sops.secrets.supabase-env = { }; # --- Persistent data directories --- # Postgres data lives in a named podman volume (supabase-db-data) so podman # owns the permissions on the container's postgres UID; logical dumps go to # /var/backups/supabase where borg picks them up from /var. systemd.tmpfiles.rules = [ "d /var/lib/supabase/storage 0755 root root -" "d /var/lib/supabase/functions 0755 root root -" "d /var/lib/supabase/snippets 0755 root root -" "d /var/backups/supabase 0700 root root -" ]; # --- Systemd services: network, env generation, and container ordering --- systemd.services = let containerNames = [ "supabase-db" "supabase-analytics" "supabase-auth" "supabase-rest" "supabase-realtime" "supabase-storage" "supabase-imgproxy" "supabase-meta" "supabase-studio" "supabase-kong" "supabase-vector" "supabase-pooler" "supabase-functions" ]; mkContainerDeps = name: { "podman-${name}" = { after = [ "init-supabase-network.service" "supabase-env-generate.service" ]; requires = [ "init-supabase-network.service" "supabase-env-generate.service" ]; }; }; in lib.mkMerge (map mkContainerDeps containerNames ++ [ { init-supabase-network = { description = "Create supabase-net Podman network"; wantedBy = [ "multi-user.target" ]; serviceConfig = { Type = "oneshot"; RemainAfterExit = true; # '-' prefix tells systemd to ignore non-zero exit (network may already exist) ExecStart = "-${pkgs.podman}/bin/podman network create supabase-net"; }; }; supabase-env-generate = { description = "Generate Supabase per-container env files from SOPS secrets"; wantedBy = [ "multi-user.target" ]; path = [ pkgs.jq ]; serviceConfig = { Type = "oneshot"; RemainAfterExit = true; ExecStart = "${envGenerateScript} ${config.sops.secrets.supabase-env.path}"; }; }; # Seed the edge-runtime's bootstrap `main` function. The container's # entrypoint requires `/home/deno/functions/main/index.ts` to exist; # without it edge-runtime fails with "could not find an appropriate # entrypoint". Re-seed on every activation so updates to the bootstrap # are picked up, while leaving user-authored functions untouched. supabase-functions-seed = { description = "Seed Supabase edge-functions main bootstrap"; wantedBy = [ "multi-user.target" ]; serviceConfig = { Type = "oneshot"; RemainAfterExit = true; }; script = '' install -d -m 0755 /var/lib/supabase/functions/main install -m 0644 ${./functions/main/index.ts} /var/lib/supabase/functions/main/index.ts ''; }; podman-supabase-functions = { after = [ "supabase-functions-seed.service" ]; requires = [ "supabase-functions-seed.service" ]; }; # Logical daily dump of the containerised Postgres cluster. Writes to # /var/backups/supabase which is covered by the borg path /var; # /var/lib/containers (the named-volume storage) is excluded from borg, # so the dump is the only copy borg ships off-host. supabase-db-backup = { description = "pg_dumpall of the Supabase Postgres cluster"; after = [ "podman-supabase-db.service" ]; requires = [ "podman-supabase-db.service" ]; serviceConfig = { Type = "oneshot"; }; script = '' set -euo pipefail tmp=/var/backups/supabase/supabase-all.sql.tmp out=/var/backups/supabase/supabase-all.sql ${pkgs.podman}/bin/podman exec -u postgres supabase-db \ pg_dumpall -U postgres --clean --if-exists > "$tmp" mv "$tmp" "$out" ''; }; } ]); systemd.timers.supabase-db-backup = { description = "Daily Supabase Postgres dump"; wantedBy = [ "timers.target" ]; timerConfig = { OnCalendar = "*-*-* 02:30:00"; Persistent = true; }; }; # --- Containers --- virtualisation.oci-containers.containers = { # 1. PostgreSQL supabase-db = { image = "supabase/postgres:15.8.1.085"; environment = { POSTGRES_HOST = "/var/run/postgresql"; PGPORT = "5432"; POSTGRES_PORT = "5432"; PGDATABASE = "postgres"; POSTGRES_DB = "postgres"; JWT_EXP = "3600"; }; environmentFiles = [ "/run/supabase/db.env" ]; volumes = [ "supabase-db-data:/var/lib/postgresql/data" "${./sql/_supabase.sql}:/docker-entrypoint-initdb.d/migrations/97-_supabase.sql:ro" "${./sql/realtime.sql}:/docker-entrypoint-initdb.d/migrations/99-realtime.sql:ro" "${./sql/logs.sql}:/docker-entrypoint-initdb.d/migrations/99-logs.sql:ro" "${./sql/pooler.sql}:/docker-entrypoint-initdb.d/migrations/99-pooler.sql:ro" "${./sql/webhooks.sql}:/docker-entrypoint-initdb.d/init-scripts/98-webhooks.sql:ro" "${./sql/roles.sql}:/docker-entrypoint-initdb.d/init-scripts/99-roles.sql:ro" "${./sql/jwt.sql}:/docker-entrypoint-initdb.d/init-scripts/99-jwt.sql:ro" "supabase-db-config:/etc/postgresql-custom" ]; cmd = [ "postgres" "-c" "config_file=/etc/postgresql/postgresql.conf" "-c" "log_min_messages=fatal" ]; extraOptions = supabaseNet ++ [ "--network-alias=db" "--shm-size=2g" ]; }; # 2. Analytics (Logflare) supabase-analytics = { image = "supabase/logflare:1.31.2"; dependsOn = [ "supabase-db" ]; environment = { LOGFLARE_NODE_HOST = "127.0.0.1"; DB_USERNAME = "supabase_admin"; DB_DATABASE = "_supabase"; DB_HOSTNAME = "db"; DB_PORT = "5432"; DB_SCHEMA = "_analytics"; LOGFLARE_SINGLE_TENANT = "true"; LOGFLARE_SUPABASE_MODE = "true"; POSTGRES_BACKEND_SCHEMA = "_analytics"; LOGFLARE_FEATURE_FLAG_OVERRIDE = "multibackend=true"; }; environmentFiles = [ "/run/supabase/analytics.env" ]; extraOptions = supabaseNet ++ [ "--network-alias=analytics" ]; }; # 3. Auth (GoTrue) supabase-auth = { image = "supabase/gotrue:v2.186.0"; dependsOn = [ "supabase-db" "supabase-analytics" ]; environment = { GOTRUE_API_HOST = "0.0.0.0"; GOTRUE_API_PORT = "9999"; API_EXTERNAL_URL = "https://supabase.cloonar.com"; GOTRUE_DB_DRIVER = "postgres"; GOTRUE_SITE_URL = "https://app.fueltide.io"; GOTRUE_URI_ALLOW_LIST = "https://app.fueltide.io,https://app.fueltide.io/**,https://app.stage.fueltide.io,https://app.stage.fueltide.io/**,io.fueltide.workout://"; GOTRUE_DISABLE_SIGNUP = "false"; GOTRUE_JWT_ADMIN_ROLES = "service_role"; GOTRUE_JWT_AUD = "authenticated"; GOTRUE_JWT_DEFAULT_GROUP_NAME = "authenticated"; GOTRUE_JWT_EXP = "3600"; GOTRUE_EXTERNAL_EMAIL_ENABLED = "true"; GOTRUE_EXTERNAL_ANONYMOUS_USERS_ENABLED = "false"; GOTRUE_MAILER_AUTOCONFIRM = "false"; GOTRUE_SMTP_ADMIN_EMAIL = "noreply@fueltide.io"; GOTRUE_SMTP_HOST = "mail.cloonar.com"; GOTRUE_SMTP_PORT = "587"; GOTRUE_SMTP_SENDER_NAME = "Fueltide"; GOTRUE_MAILER_URLPATHS_INVITE = "/auth/v1/verify"; GOTRUE_MAILER_URLPATHS_CONFIRMATION = "/auth/v1/verify"; GOTRUE_MAILER_URLPATHS_RECOVERY = "/auth/v1/verify"; GOTRUE_MAILER_URLPATHS_EMAIL_CHANGE = "/auth/v1/verify"; GOTRUE_EXTERNAL_PHONE_ENABLED = "false"; GOTRUE_SMS_AUTOCONFIRM = "false"; GOTRUE_EXTERNAL_GOOGLE_ENABLED = "true"; GOTRUE_EXTERNAL_GOOGLE_REDIRECT_URI = "https://supabase.cloonar.com/auth/v1/callback"; GOTRUE_EXTERNAL_APPLE_ENABLED = "true"; GOTRUE_EXTERNAL_APPLE_CLIENT_ID = "io.fueltide.workout"; }; environmentFiles = [ "/run/supabase/auth.env" ]; extraOptions = supabaseNet ++ [ "--network-alias=auth" ]; }; # 4. REST (PostgREST) supabase-rest = { image = "postgrest/postgrest:v14.6"; dependsOn = [ "supabase-db" ]; environment = { PGRST_DB_SCHEMAS = "public,storage,graphql_public"; PGRST_DB_MAX_ROWS = "1000"; PGRST_DB_EXTRA_SEARCH_PATH = "public"; PGRST_DB_ANON_ROLE = "anon"; PGRST_DB_USE_LEGACY_GUCS = "false"; PGRST_APP_SETTINGS_JWT_EXP = "3600"; }; environmentFiles = [ "/run/supabase/rest.env" ]; cmd = [ "postgrest" ]; extraOptions = supabaseNet ++ [ "--network-alias=rest" ]; }; # 5. Realtime supabase-realtime = { image = "supabase/realtime:v2.76.5"; dependsOn = [ "supabase-db" ]; environment = { PORT = "4000"; DB_HOST = "db"; DB_PORT = "5432"; DB_USER = "supabase_admin"; DB_NAME = "postgres"; DB_AFTER_CONNECT_QUERY = "SET search_path TO _realtime"; DB_ENC_KEY = "supabaserealtime"; ERL_AFLAGS = "-proto_dist inet_tcp"; DNS_NODES = "''"; RLIMIT_NOFILE = "10000"; APP_NAME = "realtime"; SEED_SELF_HOST = "true"; RUN_JANITOR = "true"; DISABLE_HEALTHCHECK_LOGGING = "true"; }; environmentFiles = [ "/run/supabase/realtime.env" ]; extraOptions = supabaseNet ++ [ # Hostname must be realtime-dev.supabase-realtime for tenant ID parsing "--hostname=realtime-dev.supabase-realtime" "--network-alias=realtime-dev.supabase-realtime" ]; }; # 6. Storage supabase-storage = { image = "supabase/storage-api:v1.44.2"; dependsOn = [ "supabase-db" "supabase-rest" "supabase-imgproxy" ]; environment = { POSTGREST_URL = "http://rest:3000"; STORAGE_PUBLIC_URL = "https://supabase.cloonar.com"; REQUEST_ALLOW_X_FORWARDED_PATH = "true"; FILE_SIZE_LIMIT = "52428800"; STORAGE_BACKEND = "file"; GLOBAL_S3_BUCKET = "stub"; FILE_STORAGE_BACKEND_PATH = "/var/lib/storage"; TENANT_ID = "stub"; REGION = "stub"; ENABLE_IMAGE_TRANSFORMATION = "true"; IMGPROXY_URL = "http://imgproxy:5001"; }; environmentFiles = [ "/run/supabase/storage.env" ]; volumes = [ "/var/lib/supabase/storage:/var/lib/storage" ]; extraOptions = supabaseNet ++ [ "--network-alias=storage" ]; }; # 7. Imgproxy supabase-imgproxy = { image = "darthsim/imgproxy:v3.30.1"; environment = { IMGPROXY_BIND = ":5001"; IMGPROXY_LOCAL_FILESYSTEM_ROOT = "/"; IMGPROXY_USE_ETAG = "true"; IMGPROXY_AUTO_WEBP = "true"; IMGPROXY_MAX_SRC_RESOLUTION = "16.8"; }; volumes = [ "/var/lib/supabase/storage:/var/lib/storage" ]; extraOptions = supabaseNet ++ [ "--network-alias=imgproxy" ]; }; # 8. Meta (pg-meta) supabase-meta = { image = "supabase/postgres-meta:v0.95.2"; dependsOn = [ "supabase-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"; }; environmentFiles = [ "/run/supabase/meta.env" ]; extraOptions = supabaseNet ++ [ "--network-alias=meta" ]; }; # 9. Studio supabase-studio = { image = "supabase/studio:2026.03.16-sha-5528817"; dependsOn = [ "supabase-analytics" ]; environment = { HOSTNAME = "::"; STUDIO_PG_META_URL = "http://meta:8080"; POSTGRES_PORT = "5432"; POSTGRES_HOST = "db"; POSTGRES_DB = "postgres"; PGRST_DB_SCHEMAS = "public,storage,graphql_public"; PGRST_DB_MAX_ROWS = "1000"; PGRST_DB_EXTRA_SEARCH_PATH = "public"; DEFAULT_ORGANIZATION_NAME = "Default Organization"; DEFAULT_PROJECT_NAME = "Default Project"; SUPABASE_URL = "http://kong:8000"; SUPABASE_PUBLIC_URL = "https://supabase.cloonar.com"; NEXT_PUBLIC_ENABLE_LOGS = "true"; NEXT_ANALYTICS_BACKEND_PROVIDER = "postgres"; LOGFLARE_URL = "http://analytics:4000"; SNIPPETS_MANAGEMENT_FOLDER = "/app/snippets"; EDGE_FUNCTIONS_MANAGEMENT_FOLDER = "/app/edge-functions"; }; environmentFiles = [ "/run/supabase/studio.env" ]; volumes = [ "/var/lib/supabase/snippets:/app/snippets" "/var/lib/supabase/functions:/app/edge-functions" ]; extraOptions = supabaseNet ++ [ "--network-alias=studio" ]; }; # 10. Kong (API Gateway) supabase-kong = { image = "kong/kong:3.9.1"; dependsOn = [ "supabase-studio" ]; environment = { KONG_DATABASE = "off"; KONG_DECLARATIVE_CONFIG = "/usr/local/kong/kong.yml"; KONG_DNS_ORDER = "LAST,A,CNAME"; KONG_DNS_NOT_FOUND_TTL = "1"; KONG_PLUGINS = "request-transformer,cors,key-auth,acl,basic-auth,request-termination,ip-restriction,post-function"; KONG_NGINX_PROXY_PROXY_BUFFER_SIZE = "160k"; KONG_NGINX_PROXY_PROXY_BUFFERS = "64 160k"; KONG_PROXY_ACCESS_LOG = "/dev/stdout combined"; }; environmentFiles = [ "/run/supabase/kong.env" ]; ports = [ "127.0.0.1:8000:8000" "127.0.0.1:8443:8443" ]; volumes = [ "${./kong.yml}:/home/kong/temp.yml:ro" "${kongEntrypoint}:/home/kong/kong-entrypoint.sh:ro" ]; entrypoint = "/home/kong/kong-entrypoint.sh"; extraOptions = supabaseNet ++ [ "--network-alias=kong" ]; }; # 11. Vector (log collection) supabase-vector = { image = "timberio/vector:0.53.0-alpine"; environment = { }; environmentFiles = [ "/run/supabase/vector.env" ]; volumes = [ "${./vector.yml}:/etc/vector/vector.yml:ro" "/var/run/docker.sock:/var/run/docker.sock:ro" ]; cmd = [ "--config" "/etc/vector/vector.yml" ]; extraOptions = supabaseNet ++ [ "--network-alias=vector" "--security-opt=label=disable" ]; }; # 12. Pooler (Supavisor) supabase-pooler = { image = "supabase/supavisor:2.7.4"; dependsOn = [ "supabase-db" ]; environment = { PORT = "4000"; CLUSTER_POSTGRES = "true"; REGION = "local"; ERL_AFLAGS = "-proto_dist inet_tcp"; POOLER_POOL_MODE = "transaction"; POSTGRES_PORT = "5432"; POSTGRES_DB = "postgres"; POOLER_TENANT_ID = "default-tenant"; POOLER_DEFAULT_POOL_SIZE = "20"; POOLER_MAX_CLIENT_CONN = "100"; DB_POOL_SIZE = "10"; }; environmentFiles = [ "/run/supabase/pooler.env" ]; volumes = [ "${./pooler.exs}:/etc/pooler/pooler.exs:ro" ]; cmd = [ "/bin/sh" "-c" "/app/bin/migrate && /app/bin/supavisor eval \"$(cat /etc/pooler/pooler.exs)\" && /app/bin/server" ]; extraOptions = supabaseNet ++ [ "--network-alias=pooler" ]; }; # 13. Edge Functions supabase-functions = { image = "supabase/edge-runtime:v1.71.2"; dependsOn = [ "supabase-kong" ]; environment = { SUPABASE_URL = "http://kong:8000"; SUPABASE_PUBLIC_URL = "https://supabase.cloonar.com"; VERIFY_JWT = "false"; }; environmentFiles = [ "/run/supabase/functions.env" ]; volumes = [ "/var/lib/supabase/functions:/home/deno/functions" "supabase-deno-cache:/root/.cache/deno" ]; cmd = [ "start" "--main-service" "/home/deno/functions/main" ]; extraOptions = supabaseNet ++ [ "--network-alias=functions" ]; }; }; # --- Nginx reverse proxy --- services.nginx.virtualHosts."supabase.cloonar.com" = { forceSSL = true; enableACME = true; acmeRoot = null; locations."/" = { proxyPass = "http://127.0.0.1:8000"; proxyWebsockets = true; extraConfig = '' proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; proxy_set_header X-Forwarded-Proto $scheme; proxy_read_timeout 86400s; proxy_send_timeout 86400s; client_max_body_size 50M; ''; }; }; }