Enables the auth providers and transactional email flows the self-hosted Supabase was missing compared to the cloud instance: - GoTrue now accepts Google and Apple OAuth (web flow); Apple client-secret JWT is signed fresh on every activation from the SOPS-stored .p8 so there's no 6-month rotation ritual. - SMTP points at mail.cloonar.com:587 with SASL auth via a new `supabase` LDAP account; a `noreply@fueltide.io` mailAlias lets that account send as the fueltide.io address. - rspamd on mail.cloonar.com gets a per-domain DKIM key for fueltide.io (selector `default`) so outbound mail is signed. - MAILER_AUTOCONFIRM is off so signup confirmation + password reset actually go through email. - SITE_URL + URI_ALLOW_LIST point at app.fueltide.io / stage so links in emails and OAuth redirects land in the right app. FUELTIDE_AUTH_SETUP.md documents the manual steps (LDAP entries, SOPS additions, DNS records, Google/Apple console setup) that must be completed before merging.
491 lines
17 KiB
Nix
491 lines
17 KiB
Nix
{ 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" ];
|
|
# python+cryptography is used to sign the Apple OAuth client-secret JWT
|
|
# (ES256) inside env-generate.sh.
|
|
path = [
|
|
pkgs.jq
|
|
(pkgs.python3.withPackages (ps: [ ps.cryptography ]))
|
|
];
|
|
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_REDIRECT_URI = "https://supabase.cloonar.com/auth/v1/callback";
|
|
};
|
|
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;
|
|
'';
|
|
};
|
|
};
|
|
}
|