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.
- package/carbon/backup/compose-backup.sh +45 -121
- package/carbon/db/Dockerfile +14 -3
- package/carbon/docker-compose.dns01.prod.yml +42 -0
- package/carbon/docker-compose.metabase.prod.yml +1 -2
- package/carbon/docker-compose.n8n.prod.yml +1 -2
- package/carbon/docker-compose.prod.yml +9 -7
- package/carbon/k8s/base/backup/configmap-walg.yaml +47 -0
- package/carbon/k8s/base/backup/cronjob.yaml +67 -92
- package/carbon/k8s/base/backup/kustomization.yaml +1 -0
- package/carbon/k8s/base/backup/network-policy.yaml +9 -14
- package/carbon/k8s/base/backup/rbac.yaml +40 -0
- package/carbon/k8s/base/network-policies.yaml +33 -0
- package/carbon/k8s/base/traefik/certificate.yaml +13 -7
- package/carbon/k8s/values/supabase.values.yaml +152 -0
- package/carbon/scripts/docker-up.js +13 -1
- package/carbon/src/client/components/DeploymentScenariosSection.tsx +171 -0
- package/carbon/src/client/locales/de.json +29 -0
- package/carbon/src/client/locales/en.json +29 -0
- package/carbon/src/client/locales/es.json +29 -0
- package/carbon/src/client/locales/fr.json +29 -0
- package/carbon/src/client/locales/pt.json +29 -0
- package/carbon/src/client/pages/Home.tsx +5 -0
- package/package.json +1 -2
- package/src/backup.js +13 -66
- package/src/create.js +9 -4
- package/src/deploy.js +12 -0
- package/src/destroy.js +5 -43
- package/src/lib/backup-s3.js +0 -31
- package/src/lib/cloudflare.js +0 -59
- package/src/lib/config.js +1 -42
- package/src/lib/deploy/acme.js +57 -0
- package/src/lib/deploy/admin-user.js +105 -0
- package/src/lib/deploy/bundle.js +137 -5
- package/src/lib/deploy/compose/ha.js +208 -92
- package/src/lib/deploy/compose/index.js +292 -424
- package/src/lib/deploy/k8s/ha/index.js +12 -148
- package/src/lib/deploy/k8s/index.js +1 -21
- package/src/lib/deploy/k8s/k3s.js +407 -168
- package/src/lib/deploy/orchestrator.js +90 -33
- package/src/lib/deploy/utils.js +20 -0
- package/src/lib/hetzner-guided-setup.js +0 -7
- package/src/lib/iac/index.js +45 -16
- package/src/lib/images.js +17 -0
- package/src/lib/licensing/index.js +1 -59
- package/src/lib/licensing/tiers.js +0 -8
- package/src/lib/pod-backups.js +53 -0
- package/src/lib/providers/index.js +0 -47
- package/src/lib/ssh.js +12 -0
- package/src/restore.js +216 -302
- package/src/scale.js +76 -77
- package/carbon/backup/Dockerfile +0 -75
- package/carbon/backup/backup.sh +0 -95
- package/carbon/runtime.Dockerfile +0 -17
- package/src/lib/deploy/compose/acme-verify.js +0 -121
- package/src/lib/deploy/index.js +0 -119
- 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
|
|
8
|
-
#
|
|
9
|
-
# old backups
|
|
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
|
-
#
|
|
12
|
-
#
|
|
13
|
-
#
|
|
14
|
-
#
|
|
15
|
-
#
|
|
16
|
-
#
|
|
17
|
-
#
|
|
18
|
-
#
|
|
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
|
-
|
|
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 "
|
|
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
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
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
|
-
|
|
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
|
-
|
|
124
|
-
|
|
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})"
|
package/carbon/db/Dockerfile
CHANGED
|
@@ -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
|
|
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-
|
|
17
|
-
&& mv wal-g-pg-ubuntu-
|
|
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
|
|
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
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
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
|
|
@@ -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
|
-
#
|
|
14
|
-
-
|
|
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:
|
|
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
|