vibecarbon 0.3.1 → 0.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (56) hide show
  1. package/carbon/backup/compose-backup.sh +45 -121
  2. package/carbon/db/Dockerfile +14 -3
  3. package/carbon/docker-compose.dns01.prod.yml +42 -0
  4. package/carbon/docker-compose.metabase.prod.yml +1 -2
  5. package/carbon/docker-compose.n8n.prod.yml +1 -2
  6. package/carbon/docker-compose.prod.yml +9 -7
  7. package/carbon/k8s/base/backup/configmap-walg.yaml +47 -0
  8. package/carbon/k8s/base/backup/cronjob.yaml +67 -92
  9. package/carbon/k8s/base/backup/kustomization.yaml +1 -0
  10. package/carbon/k8s/base/backup/network-policy.yaml +9 -14
  11. package/carbon/k8s/base/backup/rbac.yaml +40 -0
  12. package/carbon/k8s/base/network-policies.yaml +33 -0
  13. package/carbon/k8s/base/traefik/certificate.yaml +13 -7
  14. package/carbon/k8s/values/supabase.values.yaml +152 -0
  15. package/carbon/scripts/docker-up.js +13 -1
  16. package/carbon/src/client/components/DeploymentScenariosSection.tsx +171 -0
  17. package/carbon/src/client/locales/de.json +29 -0
  18. package/carbon/src/client/locales/en.json +29 -0
  19. package/carbon/src/client/locales/es.json +29 -0
  20. package/carbon/src/client/locales/fr.json +29 -0
  21. package/carbon/src/client/locales/pt.json +29 -0
  22. package/carbon/src/client/pages/Home.tsx +5 -0
  23. package/package.json +1 -2
  24. package/src/backup.js +13 -66
  25. package/src/create.js +9 -4
  26. package/src/deploy.js +12 -0
  27. package/src/destroy.js +5 -43
  28. package/src/lib/backup-s3.js +0 -31
  29. package/src/lib/cloudflare.js +0 -59
  30. package/src/lib/config.js +1 -42
  31. package/src/lib/deploy/acme.js +57 -0
  32. package/src/lib/deploy/admin-user.js +105 -0
  33. package/src/lib/deploy/bundle.js +137 -5
  34. package/src/lib/deploy/compose/ha.js +208 -92
  35. package/src/lib/deploy/compose/index.js +292 -424
  36. package/src/lib/deploy/k8s/ha/index.js +12 -148
  37. package/src/lib/deploy/k8s/index.js +1 -21
  38. package/src/lib/deploy/k8s/k3s.js +407 -168
  39. package/src/lib/deploy/orchestrator.js +90 -33
  40. package/src/lib/deploy/utils.js +20 -0
  41. package/src/lib/hetzner-guided-setup.js +0 -7
  42. package/src/lib/iac/index.js +45 -16
  43. package/src/lib/images.js +17 -0
  44. package/src/lib/licensing/index.js +1 -59
  45. package/src/lib/licensing/tiers.js +0 -8
  46. package/src/lib/pod-backups.js +53 -0
  47. package/src/lib/providers/index.js +0 -47
  48. package/src/lib/ssh.js +12 -0
  49. package/src/restore.js +216 -302
  50. package/src/scale.js +76 -77
  51. package/carbon/backup/Dockerfile +0 -75
  52. package/carbon/backup/backup.sh +0 -95
  53. package/carbon/runtime.Dockerfile +0 -17
  54. package/src/lib/deploy/compose/acme-verify.js +0 -121
  55. package/src/lib/deploy/index.js +0 -119
  56. package/src/lib/kubectl.js +0 -81
@@ -2,139 +2,63 @@
2
2
  set -e
3
3
 
4
4
  # ============================================================================
5
- # Vibecarbon Compose Backup Script
5
+ # Vibecarbon Compose Backup Script — single source of truth for compose backups
6
6
  #
7
- # Runs via cron on the VPS. Creates a full backup archive containing all
8
- # PostgreSQL databases and storage files, uploads to S3, and cleans up
9
- # old backups based on retention policy.
7
+ # Runs via cron on the VPS (and on-demand from the CLI). Triggers a wal-g base
8
+ # backup from inside the db container (which already has WALG_S3_PREFIX + AWS_*
9
+ # configured from .env) and prunes old base backups via `wal-g delete retain`.
10
10
  #
11
- # Environment Variables (loaded from .backup-env):
12
- # PROJECT_NAME - Project name for backup naming
13
- # BACKUP_RETENTION_DAYS - Days to keep backups (default: 30)
14
- # S3_BACKUP_BUCKET - S3 bucket for backup storage
15
- # S3_ACCESS_KEY - S3 access key
16
- # S3_SECRET_KEY - S3 secret key
17
- # S3_ENDPOINT - S3 endpoint URL
18
- # S3_REGION - S3 region
11
+ # Invoked as `cd <project-dir> && RETAIN=<n> bash backup/compose-backup.sh`, so
12
+ # it operates relative to the current working directory — no PROJECT_NAME
13
+ # needed. Both the cron path and the on-demand path use the SAME invocation
14
+ # (src/lib/deploy/compose/index.js composeBackupCmd), so the quoting/behavior
15
+ # can never drift.
16
+ #
17
+ # Environment Variables:
18
+ # RETAIN - Number of full base backups to keep (default: 7)
19
19
  # ============================================================================
20
20
 
21
- # Prevent concurrent backups
21
+ # Prevent concurrent backups (cron + a manual run racing each other).
22
22
  LOCKFILE="/tmp/vibecarbon-backup.lock"
23
23
  exec 200>"${LOCKFILE}"
24
24
  flock -n 200 || { echo "Backup already running — skipping"; exit 0; }
25
25
 
26
- PROJECT="${PROJECT_NAME:?PROJECT_NAME is required}"
27
- REMOTE_DIR="/opt/${PROJECT}"
28
- TIMESTAMP=$(date +%Y%m%d_%H%M%S)
29
- WORK_DIR="/tmp/backup_${TIMESTAMP}"
30
- ARCHIVE_NAME="${PROJECT}_${TIMESTAMP}_full.tar.gz"
31
- RETENTION_DAYS="${BACKUP_RETENTION_DAYS:-30}"
26
+ RETAIN="${RETAIN:-7}"
32
27
 
33
- echo "=== Vibecarbon Compose Backup ==="
28
+ echo "=== Vibecarbon Compose Backup (wal-g) ==="
34
29
  echo "Started at $(date)"
35
- echo "Retention: ${RETENTION_DAYS} days"
36
-
37
- mkdir -p "${WORK_DIR}"
38
-
39
- # ============================================================================
40
- # Step 1: Dump postgres database
41
- # ============================================================================
42
-
43
- echo "Backing up postgres database..."
44
- cd "${REMOTE_DIR}" && docker compose exec -T db pg_dump -U postgres postgres \
45
- | gzip > "${WORK_DIR}/postgres.sql.gz"
46
- DB_SIZE=$(du -h "${WORK_DIR}/postgres.sql.gz" | cut -f1)
47
- echo "postgres: ${DB_SIZE}"
48
-
49
- # ============================================================================
50
- # Step 2: Dump optional service databases (n8n, metabase)
51
- # ============================================================================
30
+ echo "Retain: ${RETAIN} full base backups"
52
31
 
53
- for EXTRA_DB in n8n metabase; do
54
- DB_EXISTS=$(cd "${REMOTE_DIR}" && docker compose exec -T db \
55
- psql -U postgres -tAc "SELECT 1 FROM pg_database WHERE datname='${EXTRA_DB}'" 2>/dev/null || echo "")
56
- if [ "${DB_EXISTS}" = "1" ]; then
57
- echo "Backing up ${EXTRA_DB} database..."
58
- cd "${REMOTE_DIR}" && docker compose exec -T db pg_dump -U postgres "${EXTRA_DB}" \
59
- | gzip > "${WORK_DIR}/${EXTRA_DB}.sql.gz"
60
- EXTRA_SIZE=$(du -h "${WORK_DIR}/${EXTRA_DB}.sql.gz" | cut -f1)
61
- echo "${EXTRA_DB}: ${EXTRA_SIZE}"
32
+ # wal-g connects to PG via libpq; PGUSER=supabase_admin is REQUIRED (only the
33
+ # superuser may call pg_backup_start; unset, libpq defaults to OS user root →
34
+ # "role root does not exist"). The pg_is_in_recovery()='f' guard makes the
35
+ # backup a no-op on a standby (exit 0), so only the primary pushes to S3.
36
+ #
37
+ # The S3-configured guard exits 0 BEFORE wal-g runs when no S3 backup target is
38
+ # configured. docker-compose.yml renders WALG_S3_PREFIX as
39
+ # s3://${S3_BACKUP_BUCKET:-${S3_BUCKET:-}}/backups/${PROJECT_NAME}/walg
40
+ # so an unconfigured deploy yields the empty-bucket form `s3:///backups/...`
41
+ # (and empty AWS_ACCESS_KEY_ID). Without this guard an always-installed cron
42
+ # would `set -e`-fail nightly into backup.log on no-S3 deploys. Checking inside
43
+ # the container is robust: it tests the actual env wal-g sees (source of truth),
44
+ # covers both the cron and on-demand paths, and needs no deploy-time S3 plumbing.
45
+ docker compose exec -T db bash -c '
46
+ export PGUSER=supabase_admin PGHOST=localhost PGPORT=5432 PGDATABASE=postgres
47
+ case "${WALG_S3_PREFIX:-}" in
48
+ ""|s3:///*)
49
+ echo "scheduled backup skipped: no S3 backup target configured (empty WALG_S3_PREFIX)."
50
+ exit 0
51
+ ;;
52
+ esac
53
+ if [ -z "${AWS_ACCESS_KEY_ID:-}" ] || [ -z "${AWS_SECRET_ACCESS_KEY:-}" ]; then
54
+ echo "scheduled backup skipped: no S3 credentials configured (empty AWS_ACCESS_KEY_ID/SECRET)."
55
+ exit 0
62
56
  fi
63
- done
64
-
65
- # ============================================================================
66
- # Step 3: Storage files (file backend only)
67
- # ============================================================================
68
-
69
- STORAGE_COUNT=$(cd "${REMOTE_DIR}" && docker compose exec -T storage \
70
- sh -c 'find /var/lib/storage -type f 2>/dev/null | wc -l' 2>/dev/null || echo "0")
71
- if [ "${STORAGE_COUNT}" -gt 0 ] 2>/dev/null; then
72
- echo "Backing up ${STORAGE_COUNT} storage files..."
73
- cd "${REMOTE_DIR}" && docker compose exec -T storage \
74
- tar czf - -C / var/lib/storage > "${WORK_DIR}/storage.tar.gz"
75
- STORAGE_SIZE=$(du -h "${WORK_DIR}/storage.tar.gz" | cut -f1)
76
- echo "Storage: ${STORAGE_SIZE}"
77
- fi
78
-
79
- # ============================================================================
80
- # Step 4: Create combined archive
81
- # ============================================================================
82
-
83
- echo "Creating archive: ${ARCHIVE_NAME}"
84
- tar czf "${REMOTE_DIR}/backups/${ARCHIVE_NAME}" -C "${WORK_DIR}" .
85
- TOTAL_SIZE=$(du -h "${REMOTE_DIR}/backups/${ARCHIVE_NAME}" | cut -f1)
86
- echo "Total archive size: ${TOTAL_SIZE}"
87
-
88
- # ============================================================================
89
- # Step 5: Upload to S3 (if configured)
90
- # ============================================================================
91
-
92
- if [ -n "${S3_BACKUP_BUCKET}" ] && [ -n "${S3_ACCESS_KEY}" ] && [ -n "${S3_SECRET_KEY}" ] && [ -n "${S3_ENDPOINT}" ]; then
93
- echo "Uploading to S3: s3://${S3_BACKUP_BUCKET}/backups/${ARCHIVE_NAME}"
94
-
95
- export AWS_ACCESS_KEY_ID="${S3_ACCESS_KEY}"
96
- export AWS_SECRET_ACCESS_KEY="${S3_SECRET_KEY}"
97
- export AWS_DEFAULT_REGION="${S3_REGION:-us-east-1}"
98
-
99
- aws s3 cp "${REMOTE_DIR}/backups/${ARCHIVE_NAME}" \
100
- "s3://${S3_BACKUP_BUCKET}/backups/${ARCHIVE_NAME}" \
101
- --endpoint-url "${S3_ENDPOINT}"
102
-
103
- echo "Upload complete"
104
-
105
- # S3 retention cleanup
106
- echo "Cleaning up S3 backups older than ${RETENTION_DAYS} days..."
107
- CUTOFF_DATE=$(date -d "-${RETENTION_DAYS} days" +%Y%m%d 2>/dev/null || echo "")
108
- if [ -n "${CUTOFF_DATE}" ]; then
109
- aws s3api list-objects-v2 \
110
- --bucket "${S3_BACKUP_BUCKET}" \
111
- --prefix "backups/${PROJECT}_" \
112
- --endpoint-url "${S3_ENDPOINT}" \
113
- --query "Contents[].Key" \
114
- --output text 2>/dev/null | tr '\t' '\n' | while read -r key; do
115
- [ -z "${key}" ] || [ "${key}" = "None" ] && continue
116
- FILE_DATE=$(echo "${key}" | sed -n 's/.*_\([0-9]\{8\}\)_[0-9]\{6\}_full\.tar\.gz$/\1/p')
117
- if [ -n "${FILE_DATE}" ] && [ "${FILE_DATE}" -lt "${CUTOFF_DATE}" ] 2>/dev/null; then
118
- echo "Deleting expired backup: ${key}"
119
- aws s3 rm "s3://${S3_BACKUP_BUCKET}/${key}" --endpoint-url "${S3_ENDPOINT}"
120
- fi
121
- done
57
+ if [ "$(psql -U supabase_admin -d postgres -tAc "SELECT pg_is_in_recovery()")" != "f" ]; then
58
+ echo "supabase-db is in recovery (standby) — skipping base backup."
59
+ exit 0
122
60
  fi
123
- else
124
- echo "No S3 configured — backup stored locally only"
125
- fi
126
-
127
- # ============================================================================
128
- # Step 6: Local retention cleanup
129
- # ============================================================================
130
-
131
- find "${REMOTE_DIR}/backups" -name "${PROJECT}_*_full.tar.gz" -mtime "+${RETENTION_DAYS}" -delete 2>/dev/null || true
132
-
133
- # ============================================================================
134
- # Cleanup
135
- # ============================================================================
136
-
137
- rm -rf "${WORK_DIR}"
61
+ wal-g backup-push "$PGDATA" && wal-g delete retain FULL "'"${RETAIN}"'" --confirm
62
+ '
138
63
 
139
64
  echo "=== Backup completed at $(date) ==="
140
- echo "Archive: ${ARCHIVE_NAME} (${TOTAL_SIZE})"
@@ -1,20 +1,31 @@
1
1
  # syntax=docker/dockerfile:1
2
2
  FROM supabase/postgres:15.8.1.085
3
3
 
4
- # Install WAL-G v3.0.5 kept in lockstep with carbon/backup/Dockerfile.
4
+ # Install WAL-G v3.0.5. This image IS the backup tooling now (both compose
5
+ # and k8s): wal-g runs co-located with PGDATA in this container for continuous
6
+ # WAL archiving, base backups, and restore — no separate backup image.
5
7
  # Earlier v3.0.3 URL 404'd in 2026-04-26 (alpine variant retired upstream),
6
8
  # and curl without --fail piped a 404 HTML body into tar which surfaced
7
9
  # as "not in gzip format" instead of an honest HTTP error. --fail makes
8
10
  # curl exit non-zero on 4xx/5xx so the failure is unmistakable.
9
11
  #
12
+ # Build target MUST match the base image's glibc: supabase/postgres:15.8.1.085
13
+ # is Ubuntu 20.04 (glibc 2.31). The ubuntu-22.04 wal-g build is linked against
14
+ # glibc 2.35 and dies at runtime with `version 'GLIBC_2.3{2,3,4}' not found`
15
+ # — which breaks BOTH base backups and WAL archiving (the archive_command
16
+ # shells out to wal-g), and is invisible at deploy time because we only assert
17
+ # archive_mode=on, never that wal-g actually runs. The ubuntu-20.04 build links
18
+ # against glibc 2.31 and runs on this base. (RCA 2026-05-30: e2e k8s/k8s-ha
19
+ # backup step — wal-g exited 1 with the GLIBC error inside the db pod.)
20
+ #
10
21
  # Single RUN with `apt-get purge -y --auto-remove curl` collapses the
11
22
  # install + cleanup into one layer so the curl binary doesn't end up
12
23
  # in the published image (saving ~5MB and reducing attack surface).
13
24
  RUN apt-get update && apt-get install -y --no-install-recommends \
14
25
  curl \
15
26
  ca-certificates \
16
- && curl -fL https://github.com/wal-g/wal-g/releases/download/v3.0.5/wal-g-pg-ubuntu-22.04-amd64.tar.gz | tar -xz \
17
- && mv wal-g-pg-ubuntu-22.04-amd64 /usr/local/bin/wal-g \
27
+ && curl -fL https://github.com/wal-g/wal-g/releases/download/v3.0.5/wal-g-pg-ubuntu-20.04-amd64.tar.gz | tar -xz \
28
+ && mv wal-g-pg-ubuntu-20.04-amd64 /usr/local/bin/wal-g \
18
29
  && chmod +x /usr/local/bin/wal-g \
19
30
  && apt-get purge -y --auto-remove curl \
20
31
  && rm -rf /var/lib/apt/lists/*
@@ -0,0 +1,42 @@
1
+ # DNS-01 ACME override — applied ONLY for managed-DNS deploys (cloudflare / hetzner).
2
+ #
3
+ # Activated by the deploy bundler (src/lib/deploy/bundle.js -> composeFileFlags)
4
+ # when the deploy manages DNS via an API. It is appended AFTER
5
+ # docker-compose.prod.yml, so the `command:` below REPLACES the base Traefik
6
+ # command — Compose replaces sequences, it does not merge them. That means the
7
+ # full command list must be restated here; keep every non-ACME flag in sync
8
+ # with docker-compose.prod.yml. Only the two `httpchallenge` lines are swapped
9
+ # for the three `dnschallenge` lines.
10
+ #
11
+ # Why: cloudflare/hetzner expose an API lego (Traefik's ACME client) uses to
12
+ # solve the DNS-01 challenge by writing a TXT record. This removes the HTTP-01
13
+ # race against A-record propagation, so the deploy no longer needs the
14
+ # DNS-propagation poll or the Traefik cert self-heal. `manual` DNS keeps the
15
+ # base HTTP-01 path (this override is not applied for it).
16
+ #
17
+ # Tokens: lego picks the provider from ACME_DNS_PROVIDER and reads its token.
18
+ # Hetzner uses the consolidated Cloud API token HETZNER_API_TOKEN (lego v4.27+,
19
+ # Traefik >= 3.6.6; we pin v3.6.11) — the same token as server ops. Cloudflare
20
+ # uses CF_DNS_API_TOKEN. Both are passed through; only the active provider's
21
+ # token is read. All three are populated in .env by the bundler.
22
+ services:
23
+ traefik:
24
+ command:
25
+ - "--api.dashboard=true"
26
+ - "--providers.docker=true"
27
+ - "--providers.docker.endpoint=tcp://docker-socket-proxy:2375"
28
+ - "--providers.docker.exposedbydefault=false"
29
+ - "--providers.file.directory=/etc/traefik/dynamic"
30
+ - "--entrypoints.web.address=:80"
31
+ - "--entrypoints.websecure.address=:443"
32
+ - "--certificatesresolvers.letsencrypt.acme.dnschallenge=true"
33
+ - "--certificatesresolvers.letsencrypt.acme.dnschallenge.provider=${ACME_DNS_PROVIDER}"
34
+ - "--certificatesresolvers.letsencrypt.acme.dnschallenge.resolvers=1.1.1.1:53,8.8.8.8:53"
35
+ - "--certificatesresolvers.letsencrypt.acme.email={{ADMIN_EMAIL}}"
36
+ - "--certificatesresolvers.letsencrypt.acme.storage=/letsencrypt/acme.json"
37
+ - "--certificatesresolvers.letsencrypt.acme.caserver=${ACME_CA_SERVER:-https://acme-v02.api.letsencrypt.org/directory}"
38
+ - "--log.level=WARN"
39
+ environment:
40
+ ACME_DNS_PROVIDER: ${ACME_DNS_PROVIDER:-}
41
+ CF_DNS_API_TOKEN: ${CF_DNS_API_TOKEN:-}
42
+ HETZNER_API_TOKEN: ${HETZNER_API_TOKEN:-}
@@ -15,11 +15,10 @@ services:
15
15
  labels:
16
16
  # Production Traefik routing with SSL
17
17
  - "traefik.enable=true"
18
- # HTTPS router with super_admin auth
18
+ # HTTPS router with super_admin auth — cert from default store
19
19
  - "traefik.http.routers.metabase.rule=Host(`metabase.${DOMAIN}`)"
20
20
  - "traefik.http.routers.metabase.entrypoints=websecure"
21
21
  - "traefik.http.routers.metabase.tls=true"
22
- - "traefik.http.routers.metabase.tls.certresolver=letsencrypt"
23
22
  - "traefik.http.routers.metabase.middlewares=super-admin-auth@file"
24
23
  - "traefik.http.services.metabase.loadbalancer.server.port=3000"
25
24
  # HTTP to HTTPS redirect
@@ -18,11 +18,10 @@ services:
18
18
  labels:
19
19
  # Production Traefik routing with SSL
20
20
  - "traefik.enable=true"
21
- # HTTPS router with super_admin auth
21
+ # HTTPS router with super_admin auth — cert from default store
22
22
  - "traefik.http.routers.n8n.rule=Host(`n8n.${DOMAIN}`)"
23
23
  - "traefik.http.routers.n8n.entrypoints=websecure"
24
24
  - "traefik.http.routers.n8n.tls=true"
25
- - "traefik.http.routers.n8n.tls.certresolver=letsencrypt"
26
25
  - "traefik.http.routers.n8n.middlewares=super-admin-auth@file"
27
26
  - "traefik.http.services.n8n.loadbalancer.server.port=5678"
28
27
  # HTTP to HTTPS redirect
@@ -26,7 +26,12 @@ services:
26
26
  - "traefik.http.routers.app-http.entrypoints=web"
27
27
  - "traefik.http.routers.app-http.middlewares=redirect-to-https"
28
28
  - "traefik.http.middlewares.redirect-to-https.redirectscheme.scheme=https"
29
- # HTTPS router with TLS
29
+ # HTTPS router for the app at the bare apex (${DOMAIN}). The apex gets its
30
+ # OWN single-domain cert via certresolver — it is intentionally NOT in the
31
+ # default wildcard store cert, because apex + `*.${DOMAIN}` share the
32
+ # `_acme-challenge.${DOMAIN}` TXT name and some DNS providers (Hetzner)
33
+ # can't hold both challenge values at once. The subdomain routers below use
34
+ # `tls=true` (no resolver) and serve the `*.${DOMAIN}` default cert via SNI.
30
35
  - "traefik.http.routers.app.rule=Host(`${DOMAIN:-localhost}`)"
31
36
  - "traefik.http.routers.app.entrypoints=websecure"
32
37
  - "traefik.http.routers.app.tls=true"
@@ -109,10 +114,9 @@ services:
109
114
  - "traefik.http.routers.dashboard-http.rule=Host(`traefik.${DOMAIN}`)"
110
115
  - "traefik.http.routers.dashboard-http.entrypoints=web"
111
116
  - "traefik.http.routers.dashboard-http.middlewares=redirect-to-https"
112
- # HTTPS dashboard with super_admin auth
117
+ # HTTPS dashboard with super_admin auth — cert from default store
113
118
  - "traefik.http.routers.dashboard.entrypoints=websecure"
114
119
  - "traefik.http.routers.dashboard.tls=true"
115
- - "traefik.http.routers.dashboard.tls.certresolver=letsencrypt"
116
120
  - "traefik.http.routers.dashboard.middlewares=super-admin-auth@file"
117
121
  deploy:
118
122
  resources:
@@ -127,11 +131,10 @@ services:
127
131
  studio:
128
132
  labels:
129
133
  - "traefik.enable=true"
130
- # HTTPS router with admin auth
134
+ # HTTPS router with admin auth — cert from default store
131
135
  - "traefik.http.routers.studio.rule=Host(`studio.${DOMAIN}`)"
132
136
  - "traefik.http.routers.studio.entrypoints=websecure"
133
137
  - "traefik.http.routers.studio.tls=true"
134
- - "traefik.http.routers.studio.tls.certresolver=letsencrypt"
135
138
  - "traefik.http.routers.studio.middlewares=admin-auth@file"
136
139
  - "traefik.http.services.studio.loadbalancer.server.port=3000"
137
140
 
@@ -260,11 +263,10 @@ services:
260
263
  - "traefik.http.routers.kong-http.rule=Host(`api.${DOMAIN:-localhost}`)"
261
264
  - "traefik.http.routers.kong-http.entrypoints=web"
262
265
  - "traefik.http.routers.kong-http.middlewares=redirect-to-https"
263
- # HTTPS router for Supabase API
266
+ # HTTPS router for Supabase API — cert from default store
264
267
  - "traefik.http.routers.kong.rule=Host(`api.${DOMAIN:-localhost}`)"
265
268
  - "traefik.http.routers.kong.entrypoints=websecure"
266
269
  - "traefik.http.routers.kong.tls=true"
267
- - "traefik.http.routers.kong.tls.certresolver=letsencrypt"
268
270
  - "traefik.http.services.kong.loadbalancer.server.port=8000"
269
271
  deploy:
270
272
  resources:
@@ -0,0 +1,47 @@
1
+ # Fault-tolerant WAL-archive wrapper mounted into the supabase-db container at
2
+ # /etc/postgresql/wal-archive.sh (see k8s/values/supabase.values.yaml
3
+ # deployment.db.volumes/volumeMounts). Postgres' archive_command invokes it as
4
+ # `bash /etc/postgresql/wal-archive.sh %p` (enabled via ALTER SYSTEM in
5
+ # applyK3sManifests). wal-g reads WALG_S3_PREFIX / AWS_* from the db
6
+ # container's env.
7
+ #
8
+ # IMPORTANT: keep this script body byte-for-byte in sync with
9
+ # carbon/volumes/db/wal-archive.sh (the compose mount). Same retry + exit-0
10
+ # semantics: a persistent S3 outage degrades PITR for the failed segment but
11
+ # keeps the DB up instead of filling the disk.
12
+ apiVersion: v1
13
+ kind: ConfigMap
14
+ metadata:
15
+ name: vibecarbon-wal-archive
16
+ namespace: vibecarbon
17
+ labels:
18
+ app: vibecarbon-backup
19
+ component: backup
20
+ data:
21
+ wal-archive.sh: |
22
+ #!/bin/bash
23
+ # Fault-tolerant archive_command for Postgres + wal-g.
24
+ # Invoked by postgres as: wal-archive.sh <wal-file-path>
25
+ # (%p — absolute path to the WAL segment). Retries with backoff, then
26
+ # logs WAL_ARCHIVE_FAILED and exits 0 so PG recycles the segment rather
27
+ # than pinning it and filling the disk. Keep in sync with
28
+ # carbon/volumes/db/wal-archive.sh.
29
+ set -u
30
+ WAL_PATH="${1:?archive_command requires a WAL file path}"
31
+ RETRIES="${WAL_ARCHIVE_RETRIES:-3}"
32
+ SLEEP_BASE="${WAL_ARCHIVE_SLEEP:-2}"
33
+
34
+ attempt=1
35
+ while [ "$attempt" -le "$RETRIES" ]; do
36
+ if /usr/local/bin/wal-g wal-push "$WAL_PATH"; then
37
+ exit 0
38
+ fi
39
+ if [ "$attempt" -lt "$RETRIES" ]; then
40
+ sleep_for=$((SLEEP_BASE ** attempt))
41
+ sleep "$sleep_for"
42
+ fi
43
+ attempt=$((attempt + 1))
44
+ done
45
+
46
+ echo "WAL_ARCHIVE_FAILED: wal-g wal-push '$WAL_PATH' failed after $RETRIES attempts; allowing PG to recycle (PITR gap for this segment)" >&2
47
+ exit 0
@@ -1,3 +1,17 @@
1
+ # Nightly wal-g base backup.
2
+ #
3
+ # wal-g backup-push needs direct PGDATA filesystem access, which only the
4
+ # supabase-db pod has. So instead of a separate backup container that talks to
5
+ # postgres over the network (the old pg_dump design), this CronJob runs a tiny
6
+ # kubectl image and `kubectl exec`s wal-g INSIDE the db pod — where wal-g, the
7
+ # WALG_*/AWS_* env, and PGDATA already live (see k8s/values/supabase.values.yaml
8
+ # + the WAL-archiving setup in applyK3sManifests). No bespoke backup image, no
9
+ # sideload, no registry push.
10
+ #
11
+ # HA-safe: the command guards on pg_is_in_recovery()=f, so it only takes a base
12
+ # backup on the PRIMARY. On the standby (in recovery) it no-ops, and after a
13
+ # failover the new primary's CronJob transparently starts taking base backups —
14
+ # no per-cluster config.
1
15
  apiVersion: batch/v1
2
16
  kind: CronJob
3
17
  metadata:
@@ -23,98 +37,59 @@ spec:
23
37
  restartPolicy: OnFailure
24
38
  serviceAccountName: backup-sa
25
39
  containers:
26
- - name: backup
27
- image: ghcr.io/{{GITHUB_OWNER}}/{{PROJECT_NAME}}-backup:latest
28
- imagePullPolicy: Always
29
- env:
30
- - name: PGHOST
31
- valueFrom:
32
- configMapKeyRef:
33
- name: vibecarbon-config
34
- key: DB_HOST
35
- - name: PGPORT
36
- valueFrom:
37
- configMapKeyRef:
38
- name: vibecarbon-config
39
- key: DB_PORT
40
- - name: PGUSER
41
- value: supabase_admin
42
- - name: PGDATABASE
43
- valueFrom:
44
- configMapKeyRef:
45
- name: vibecarbon-config
46
- key: DB_NAME
47
- - name: PGPASSWORD
48
- valueFrom:
49
- secretKeyRef:
50
- name: vibecarbon-secrets
51
- key: DB_PASSWORD
52
- - name: S3_ACCESS_KEY
53
- valueFrom:
54
- secretKeyRef:
55
- name: vibecarbon-secrets
56
- key: S3_ACCESS_KEY
57
- optional: true
58
- - name: S3_SECRET_KEY
59
- valueFrom:
60
- secretKeyRef:
61
- name: vibecarbon-secrets
62
- key: S3_SECRET_KEY
63
- optional: true
64
- - name: S3_BUCKET
65
- valueFrom:
66
- secretKeyRef:
67
- name: vibecarbon-secrets
68
- key: S3_BUCKET
69
- optional: true
70
- - name: S3_ENDPOINT
71
- valueFrom:
72
- secretKeyRef:
73
- name: vibecarbon-secrets
74
- key: S3_ENDPOINT
75
- optional: true
76
- - name: S3_REGION
77
- valueFrom:
78
- secretKeyRef:
79
- name: vibecarbon-secrets
80
- key: S3_REGION
81
- optional: true
82
- - name: S3_BACKUP_BUCKET
83
- valueFrom:
84
- secretKeyRef:
85
- name: vibecarbon-secrets
86
- key: S3_BACKUP_BUCKET
87
- optional: true
88
- - name: BACKUP_RETENTION_DAYS
89
- valueFrom:
90
- configMapKeyRef:
91
- name: vibecarbon-config
92
- key: BACKUP_RETENTION_DAYS
93
- optional: true
94
- - name: STORAGE_BACKEND
95
- value: file
96
- # Use pg_dump (network protocol) instead of WAL-G in this
97
- # CronJob — WAL-G's backup-push needs direct filesystem access
98
- # to PGDATA, which only the supabase-db pod has. The backup
99
- # pod runs separately and connects via PGHOST, so it has to
100
- # use the libpq-based pg_dump path. Switching WAL-G on for
101
- # k8s would require a sidecar inside supabase-db.
102
- - name: BACKUP_MODE
103
- value: pg_dump
104
- - name: PROJECT_NAME
105
- valueFrom:
106
- configMapKeyRef:
107
- name: vibecarbon-config
108
- key: PROJECT_NAME
109
- resources:
110
- requests:
111
- cpu: 100m
112
- memory: 256Mi
113
- limits:
114
- cpu: 500m
115
- memory: 512Mi
116
- securityContext:
117
- allowPrivilegeEscalation: false
40
+ - name: backup
41
+ # Pinned public kubectl image — no build/sideload/push. Bump the
42
+ # tag deliberately; `latest` would risk a silent client/server
43
+ # skew on the scheduled run. Needs bash (the script below uses
44
+ # `set -euo pipefail`) + kubectl on PATH, so the distroless
45
+ # registry.k8s.io/kubectl is unusable. Was bitnami/kubectl:1.31
46
+ # until Bitnami sunset their public Docker Hub catalog (2025) and
47
+ # the tag vanished (`not found` → ImagePullBackOff); alpine/k8s
48
+ # is the maintained drop-in (alpine base, bash + kubectl).
49
+ image: alpine/k8s:1.31.12
50
+ imagePullPolicy: IfNotPresent
51
+ env:
52
+ - name: BACKUP_RETENTION_DAYS
53
+ valueFrom:
54
+ configMapKeyRef:
55
+ name: vibecarbon-config
56
+ key: BACKUP_RETENTION_DAYS
57
+ optional: true
58
+ command:
59
+ - /bin/bash
60
+ - -c
61
+ - |
62
+ set -euo pipefail
63
+ RETAIN="${BACKUP_RETENTION_DAYS:-7}"
64
+ # Exec wal-g inside the db pod. The guard makes this a no-op on
65
+ # a standby (in recovery) so only the primary takes base backups.
66
+ kubectl exec -n vibecarbon statefulset/supabase-supabase-db -- bash -c '
67
+ set -euo pipefail
68
+ # wal-g backup-push connects to Postgres (pg_backup_start) using
69
+ # libpq env. Unset, PGUSER defaults to the container OS user
70
+ # "root" → "role \"root\" does not exist". And only the cluster
71
+ # superuser may call pg_backup_start — supabase_admin is the sole
72
+ # superuser ("postgres" gets "permission denied for function
73
+ # pg_backup_start"). So pin the libpq connection explicitly.
74
+ # (wal-push/archiving and backup-fetch/restore do not connect to
75
+ # PG, which is why this only bit backup-push. RCA 2026-05-30.)
76
+ export PGUSER=supabase_admin PGHOST=localhost PGPORT=5432 PGDATABASE=postgres
77
+ if [ "$(psql -U supabase_admin -d postgres -tAc "SELECT pg_is_in_recovery()")" != "f" ]; then
78
+ echo "supabase-db is in recovery (standby) — skipping base backup."
79
+ exit 0
80
+ fi
81
+ wal-g backup-push "$PGDATA"
82
+ wal-g delete retain FULL '"${RETAIN}"' --confirm
83
+ '
84
+ resources:
85
+ requests:
86
+ cpu: 50m
87
+ memory: 64Mi
88
+ limits:
89
+ cpu: 250m
90
+ memory: 128Mi
91
+ securityContext:
92
+ allowPrivilegeEscalation: false
118
93
  securityContext:
119
94
  seccompProfile:
120
95
  type: RuntimeDefault
@@ -2,6 +2,7 @@ apiVersion: kustomize.config.k8s.io/v1beta1
2
2
  kind: Kustomization
3
3
 
4
4
  resources:
5
+ - configmap-walg.yaml
5
6
  - cronjob.yaml
6
7
  - rbac.yaml
7
8
  - network-policy.yaml
@@ -1,3 +1,9 @@
1
+ # The backup CronJob pod no longer talks to postgres:5432 or S3 directly — it
2
+ # runs `kubectl exec` into the supabase-db pod (wal-g runs there). So its only
3
+ # egress need is the Kubernetes apiserver. DNS + intra-namespace egress come
4
+ # from the cluster-baseline policies (network-policies.yaml, podSelector {}).
5
+ # The wal-g → S3 egress now happens from the supabase-db pod, allowed by the
6
+ # supabase-db-s3-egress policy in network-policies.yaml.
1
7
  apiVersion: networking.k8s.io/v1
2
8
  kind: NetworkPolicy
3
9
  metadata:
@@ -10,22 +16,11 @@ spec:
10
16
  policyTypes:
11
17
  - Egress
12
18
  egress:
13
- # Connect to PostgreSQL for pg_dump
14
- - to:
15
- - podSelector:
16
- matchLabels:
17
- app: vibecarbon-postgres
18
- ports:
19
- - protocol: TCP
20
- port: 5432
21
- # Upload to S3 (external HTTPS)
19
+ # Kubernetes apiserver (kubernetes.default ClusterIP 10.43.0.1:443) so
20
+ # `kubectl exec` works. Mirrors the app/traefik apiserver-egress pattern.
22
21
  - to:
23
22
  - ipBlock:
24
- cidr: 0.0.0.0/0
25
- except:
26
- - 10.0.0.0/8
27
- - 172.16.0.0/12
28
- - 192.168.0.0/16
23
+ cidr: 10.43.0.1/32
29
24
  ports:
30
25
  - protocol: TCP
31
26
  port: 443
@@ -5,3 +5,43 @@ metadata:
5
5
  labels:
6
6
  app: vibecarbon-backup
7
7
  component: backup
8
+ ---
9
+ # The backup CronJob runs `kubectl exec` into the supabase-db pod to invoke
10
+ # wal-g backup-push (wal-g needs local PGDATA access). That requires
11
+ # pods/exec in the vibecarbon namespace. `pods` get/list is needed so
12
+ # `kubectl exec statefulset/...` can resolve the pod.
13
+ apiVersion: rbac.authorization.k8s.io/v1
14
+ kind: Role
15
+ metadata:
16
+ name: backup-exec
17
+ namespace: vibecarbon
18
+ labels:
19
+ app: vibecarbon-backup
20
+ component: backup
21
+ rules:
22
+ - apiGroups: [""]
23
+ resources: ["pods"]
24
+ verbs: ["get", "list"]
25
+ - apiGroups: [""]
26
+ resources: ["pods/exec"]
27
+ verbs: ["create"]
28
+ - apiGroups: ["apps"]
29
+ resources: ["statefulsets"]
30
+ verbs: ["get"]
31
+ ---
32
+ apiVersion: rbac.authorization.k8s.io/v1
33
+ kind: RoleBinding
34
+ metadata:
35
+ name: backup-exec
36
+ namespace: vibecarbon
37
+ labels:
38
+ app: vibecarbon-backup
39
+ component: backup
40
+ subjects:
41
+ - kind: ServiceAccount
42
+ name: backup-sa
43
+ namespace: vibecarbon
44
+ roleRef:
45
+ kind: Role
46
+ name: backup-exec
47
+ apiGroup: rbac.authorization.k8s.io