vibecarbon 0.4.0 → 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.
@@ -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})"
@@ -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:
@@ -1,11 +1,17 @@
1
1
  # TLS Certificate for Vibecarbon application
2
2
  # Requires cert-manager to be installed (kubectl apply -k k8s/infra/)
3
3
  #
4
- # issuerRef.name is patched per-deployment by applyK3sManifests in
5
- # src/lib/deploy/k8s/k3s.js — it picks letsencrypt-{prod,staging}-{
6
- # cloudflare,hetzner,manual} based on dnsProvider + ACME_CA_SERVER.
7
- # The placeholder below keeps this template valid for kustomize and
8
- # is overwritten before cert-manager ever sees a Certificate request.
4
+ # issuerRef.name and dnsNames are BOTH patched per-deployment by
5
+ # applyK3sManifests in src/lib/deploy/k8s/k3s.js:
6
+ # - issuerRef.name → letsencrypt-{prod,staging}-{cloudflare,hetzner,manual}
7
+ # based on dnsProvider + ACME_CA_SERVER.
8
+ # - dnsNames [domain] for manual (HTTP-01) issuers;
9
+ # [domain, "*.domain"] for DNS-01 (cloudflare/hetzner) issuers.
10
+ # DNS-01 allows a wildcard SAN so one cert covers every subdomain
11
+ # (api, studio, dashboard, grafana, n8n, ...) without separate ACME orders.
12
+ #
13
+ # The placeholders below keep this template valid for kustomize; they are
14
+ # overwritten before cert-manager ever sees a Certificate request.
9
15
  apiVersion: cert-manager.io/v1
10
16
  kind: Certificate
11
17
  metadata:
@@ -17,5 +23,5 @@ spec:
17
23
  kind: ClusterIssuer
18
24
  dnsNames:
19
25
  - app.example.com # Patched with actual domain at deploy time
20
- # Optional: Include additional subdomains
21
- # - "*.app.example.com"
26
+ # For DNS-01 (managed-DNS) deploys the wildcard is added at deploy time:
27
+ # - "*.app.example.com"
@@ -174,6 +174,14 @@ deployment:
174
174
  {
175
175
  echo "restore_command = 'wal-g wal-fetch \"%f\" \"%p\"'"
176
176
  echo "recovery_target_action = 'promote'"
177
+ # Pin recovery to the fetched base backup's OWN timeline. Default
178
+ # 'latest' makes postgres chase the newest timeline with a .history
179
+ # file; in HA, repeated restore→promote cycles leave DIVERGENT
180
+ # timelines in the shared wal-g S3 prefix, and 'latest' can pick one
181
+ # that forked before this base backup → crash-loop "requested
182
+ # timeline N is not a child of this server's history". 'current'
183
+ # recovers along the base backup's timeline, then promotes fresh.
184
+ echo "recovery_target_timeline = 'current'"
177
185
  if [ "${RESTORE_TARGET}" != "latest" ]; then
178
186
  # PITR: RESTORE_TARGET is an ISO-8601 timestamp — replay only up
179
187
  # to that point instead of to the end of the WAL stream.
@@ -124,7 +124,19 @@ const manifest = loadManifest();
124
124
 
125
125
  // Get port configuration with offset applied
126
126
  const portEnv = getPortEnvVars();
127
- const execOpts = { stdio: 'inherit', env: { ...process.env, ...portEnv } };
127
+
128
+ // Pin local builds to the `default` (docker-driver) BuildKit builder.
129
+ // `docker compose up` delegates image builds to `buildx bake`, which uses the
130
+ // globally-selected builder. A long-lived `docker-container` builder (e.g. a
131
+ // `vc-multiarch` builder left active via `buildx create --use` after a
132
+ // multi-arch release build) freezes its /etc/resolv.conf at creation time — so
133
+ // after a laptop changes network/subnet it keeps querying a dead DNS server and
134
+ // builds fail with "i/o timeout" resolving registry-1.docker.io. The default
135
+ // builder builds through the daemon's live DNS on every build, sidestepping
136
+ // that whole class. Local dev/test never needs multi-arch; an explicit
137
+ // BUILDX_BUILDER in the environment still wins, for anyone who wants otherwise.
138
+ const buildEnv = { BUILDX_BUILDER: process.env.BUILDX_BUILDER || 'default' };
139
+ const execOpts = { stdio: 'inherit', env: { ...process.env, ...portEnv, ...buildEnv } };
128
140
 
129
141
  // Check if any optional services (metabase, n8n, observability, etc.) are enabled.
130
142
  // These can have slow health checks (e.g. Metabase JVM takes 2-4 min), so we start
@@ -0,0 +1,171 @@
1
+ import { motion } from 'framer-motion';
2
+ import { ArrowLeftRight, CornerDownRight, RefreshCw, Server } from 'lucide-react';
3
+ import { useTranslation } from 'react-i18next';
4
+ import vibecarbonIcon from '../assets/vibecarbon-icon.svg';
5
+
6
+ type Shape = 'single' | 'pair' | 'cluster' | 'multiCluster';
7
+ type Accent = 'teal' | 'magenta';
8
+
9
+ interface Scenario {
10
+ id: string;
11
+ shape: Shape;
12
+ accent: Accent;
13
+ }
14
+
15
+ const ACCENTS: Record<Accent, { text: string; pill: string; cluster: string; hover: string }> = {
16
+ teal: {
17
+ text: 'text-primary',
18
+ pill: 'border-primary/30 text-primary',
19
+ cluster: 'border-primary/30',
20
+ hover: 'hover:border-primary/30',
21
+ },
22
+ magenta: {
23
+ text: 'text-secondary-accent',
24
+ pill: 'border-secondary-accent/30 text-secondary-accent',
25
+ cluster: 'border-secondary-accent/30',
26
+ hover: 'hover:border-secondary-accent/30',
27
+ },
28
+ };
29
+
30
+ const SCENARIOS: Scenario[] = [
31
+ { id: 'compose', shape: 'single', accent: 'teal' },
32
+ { id: 'composeHa', shape: 'pair', accent: 'magenta' },
33
+ { id: 'k8s', shape: 'cluster', accent: 'teal' },
34
+ { id: 'k8sHa', shape: 'multiCluster', accent: 'magenta' },
35
+ ];
36
+
37
+ // A single server "node" — same visual language as WorkflowSection's DeployVisual pods.
38
+ function Node({ color }: { color: string }) {
39
+ return (
40
+ <div className="flex items-center justify-center rounded-md border border-border/60 bg-background/60 p-1.5 dark:border-white/10 dark:bg-white/[0.03]">
41
+ <Server className={`size-3.5 ${color}`} strokeWidth={1.5} />
42
+ </div>
43
+ );
44
+ }
45
+
46
+ // A dashed "cluster" outline wrapping N nodes. `count` is a fixed literal per topology
47
+ // (2 or 3), so naming the nodes gives each a stable, reorder-free key.
48
+ const NODE_KEYS = ['n1', 'n2', 'n3'];
49
+
50
+ function Cluster({ color, border, count }: { color: string; border: string; count: number }) {
51
+ return (
52
+ <div className={`flex items-center gap-1 rounded-lg border border-dashed ${border} p-1.5`}>
53
+ {NODE_KEYS.slice(0, count).map((key) => (
54
+ <Node key={key} color={color} />
55
+ ))}
56
+ </div>
57
+ );
58
+ }
59
+
60
+ // Topology glyph: 1 node / 2 nodes + failover / 3-node cluster / 2 clusters + region failover.
61
+ function Topology({ shape, accent }: { shape: Shape; accent: Accent }) {
62
+ const { text, cluster } = ACCENTS[accent];
63
+ switch (shape) {
64
+ case 'single':
65
+ return <Node color={text} />;
66
+ case 'pair':
67
+ return (
68
+ <div className="flex items-center gap-2">
69
+ <Node color={text} />
70
+ <RefreshCw className={`size-3.5 ${text}`} strokeWidth={1.5} />
71
+ <Node color={text} />
72
+ </div>
73
+ );
74
+ case 'cluster':
75
+ return <Cluster color={text} border={cluster} count={3} />;
76
+ case 'multiCluster':
77
+ return (
78
+ <div className="flex items-center gap-2">
79
+ <Cluster color={text} border={cluster} count={2} />
80
+ <ArrowLeftRight className={`size-3.5 ${text}`} strokeWidth={1.5} />
81
+ <Cluster color={text} border={cluster} count={2} />
82
+ </div>
83
+ );
84
+ }
85
+ }
86
+
87
+ export function DeploymentScenariosSection() {
88
+ const { t } = useTranslation();
89
+
90
+ return (
91
+ <section className="relative py-24 md:py-36">
92
+ <div className="mx-auto max-w-5xl px-6">
93
+ <div className="mb-10 text-center">
94
+ <motion.div
95
+ className="mb-6 flex justify-center"
96
+ animate={{ y: [0, -6, 0] }}
97
+ transition={{ duration: 3, repeat: Infinity, ease: 'easeInOut' }}
98
+ >
99
+ <img
100
+ src={vibecarbonIcon}
101
+ alt=""
102
+ width={56}
103
+ height={56}
104
+ className="drop-shadow-[0_0_12px_oklch(0.82_0.14_192/0.6)]"
105
+ />
106
+ </motion.div>
107
+ <h2 className="mb-4 text-3xl font-black tracking-tight md:text-5xl">
108
+ {t('landing.deploy.headline')}{' '}
109
+ <span className="bg-gradient-to-r from-primary via-cyan-400 to-secondary-accent bg-clip-text text-transparent">
110
+ {t('landing.deploy.headlineHighlight')}
111
+ </span>
112
+ </h2>
113
+ <p className="mx-auto max-w-2xl text-lg text-muted-foreground">
114
+ {t('landing.deploy.subheading')}
115
+ </p>
116
+ </div>
117
+
118
+ <div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
119
+ {SCENARIOS.map((scenario, index) => {
120
+ const accent = ACCENTS[scenario.accent];
121
+ const features = t(`landing.deploy.${scenario.id}.features`, {
122
+ returnObjects: true,
123
+ }) as string[];
124
+
125
+ return (
126
+ <motion.div
127
+ key={scenario.id}
128
+ initial={{ opacity: 0, y: 20 }}
129
+ whileInView={{ opacity: 1, y: 0 }}
130
+ viewport={{ once: true, margin: '-50px' }}
131
+ transition={{ duration: 0.4, delay: index * 0.08 }}
132
+ className={`group flex flex-col rounded-2xl border border-border/60 bg-muted/40 p-6 transition-all duration-300 dark:border-white/10 dark:bg-white/5 ${accent.hover} hover:bg-muted/80 dark:hover:bg-white/10`}
133
+ >
134
+ {/* Topology glyph */}
135
+ <div className="mb-5 flex h-12 items-center">
136
+ <Topology shape={scenario.shape} accent={scenario.accent} />
137
+ </div>
138
+
139
+ {/* Name + tagline */}
140
+ <h3 className="text-lg font-bold text-foreground">
141
+ {t(`landing.deploy.${scenario.id}.name`)}
142
+ </h3>
143
+ <p className="mt-1 text-sm text-muted-foreground">
144
+ {t(`landing.deploy.${scenario.id}.tagline`)}
145
+ </p>
146
+
147
+ {/* Attribute pills */}
148
+ <div className="mt-4 flex flex-wrap gap-2">
149
+ {features.map((feature) => (
150
+ <span
151
+ key={feature}
152
+ className={`rounded-full border px-2.5 py-0.5 text-xs font-medium ${accent.pill}`}
153
+ >
154
+ {feature}
155
+ </span>
156
+ ))}
157
+ </div>
158
+
159
+ {/* Best for */}
160
+ <div className="mt-5 flex items-center gap-1.5 text-sm text-muted-foreground">
161
+ <CornerDownRight className={`size-4 shrink-0 ${accent.text}`} strokeWidth={1.5} />
162
+ <span>{t(`landing.deploy.${scenario.id}.bestFor`)}</span>
163
+ </div>
164
+ </motion.div>
165
+ );
166
+ })}
167
+ </div>
168
+ </div>
169
+ </section>
170
+ );
171
+ }
@@ -394,6 +394,35 @@
394
394
  "description": "Jede Route validiert mit Zod, jede Abfrage nutzt den richtigen Supabase-Client, jede Tabelle hat RLS. KI-Agenten replizieren jedes Mal dieselben korrekten Muster."
395
395
  }
396
396
  },
397
+ "deploy": {
398
+ "headline": "Deploye nach deinen Vorstellungen,",
399
+ "headlineHighlight": "mit einem Befehl",
400
+ "subheading": "Von einem einzelnen VPS bis zu Multi-Region-Kubernetes — wähle dein Maß an Skalierung und Ausfallsicherheit. Wechsle jederzeit in die nächste Stufe.",
401
+ "compose": {
402
+ "name": "Docker Compose",
403
+ "tagline": "Ein VPS, dein kompletter Stack inklusive.",
404
+ "features": ["1 Server", "Einzelne Region"],
405
+ "bestFor": "MVPs & interne Tools"
406
+ },
407
+ "composeHa": {
408
+ "name": "Compose HA",
409
+ "tagline": "Zwei Server, automatisches Failover.",
410
+ "features": ["2 Server", "Streaming-Replikation", "Auto-Failover"],
411
+ "bestFor": "Produktions-SaaS ohne Kubernetes"
412
+ },
413
+ "k8s": {
414
+ "name": "Kubernetes",
415
+ "tagline": "Automatisch skalierender k3s-Cluster.",
416
+ "features": ["Einzelne Region", "Horizontale Autoskalierung", "Rollierende Updates"],
417
+ "bestFor": "Anwendungen mit hohem Traffic"
418
+ },
419
+ "k8sHa": {
420
+ "name": "Kubernetes HA",
421
+ "tagline": "Multi-Region-Cluster, maximale Verfügbarkeit.",
422
+ "features": ["2 Regionen", "Regions-Failover", "Autoskalierung"],
423
+ "bestFor": "Geschäftskritisch & global"
424
+ }
425
+ },
397
426
  "pricing": {
398
427
  "headline1": "Von der",
399
428
  "headlineSketch": "Idee",
@@ -394,6 +394,35 @@
394
394
  "description": "Every route validates with Zod, every query uses the right Supabase client, every table has RLS. AI agents replicate the same correct patterns every time."
395
395
  }
396
396
  },
397
+ "deploy": {
398
+ "headline": "Deploy your way,",
399
+ "headlineHighlight": "in one command",
400
+ "subheading": "From a single VPS to multi-region Kubernetes — choose your level of scale and resilience. Grow into the next tier whenever you're ready.",
401
+ "compose": {
402
+ "name": "Docker Compose",
403
+ "tagline": "One VPS, your full stack included.",
404
+ "features": ["1 server", "Single region"],
405
+ "bestFor": "MVPs & internal tools"
406
+ },
407
+ "composeHa": {
408
+ "name": "Compose HA",
409
+ "tagline": "Two servers, automatic failover.",
410
+ "features": ["2 servers", "Streaming replication", "Auto-failover"],
411
+ "bestFor": "Production SaaS without Kubernetes"
412
+ },
413
+ "k8s": {
414
+ "name": "Kubernetes",
415
+ "tagline": "Auto-scaling k3s cluster.",
416
+ "features": ["Single region", "Horizontal autoscaling", "Rolling updates"],
417
+ "bestFor": "High-traffic apps"
418
+ },
419
+ "k8sHa": {
420
+ "name": "Kubernetes HA",
421
+ "tagline": "Multi-region clusters, maximum uptime.",
422
+ "features": ["2 regions", "Region failover", "Autoscaling"],
423
+ "bestFor": "Mission-critical & global"
424
+ }
425
+ },
397
426
  "pricing": {
398
427
  "headline1": "From",
399
428
  "headlineSketch": "sketch",
@@ -394,6 +394,35 @@
394
394
  "description": "Cada ruta valida con Zod, cada consulta usa el cliente Supabase correcto, cada tabla tiene RLS. Los agentes de IA replican los mismos patrones correctos en todo momento."
395
395
  }
396
396
  },
397
+ "deploy": {
398
+ "headline": "Despliega a tu manera,",
399
+ "headlineHighlight": "en un solo comando",
400
+ "subheading": "De un único VPS a Kubernetes multirregión — elige tu nivel de escalado y resiliencia. Sube al siguiente nivel cuando quieras.",
401
+ "compose": {
402
+ "name": "Docker Compose",
403
+ "tagline": "Un VPS, con todo tu stack incluido.",
404
+ "features": ["1 servidor", "Región única"],
405
+ "bestFor": "MVPs y herramientas internas"
406
+ },
407
+ "composeHa": {
408
+ "name": "Compose HA",
409
+ "tagline": "Dos servidores, conmutación automática.",
410
+ "features": ["2 servidores", "Replicación en streaming", "Conmutación automática"],
411
+ "bestFor": "SaaS en producción sin Kubernetes"
412
+ },
413
+ "k8s": {
414
+ "name": "Kubernetes",
415
+ "tagline": "Clúster k3s con autoescalado.",
416
+ "features": ["Región única", "Autoescalado horizontal", "Actualizaciones progresivas"],
417
+ "bestFor": "Apps de alto tráfico"
418
+ },
419
+ "k8sHa": {
420
+ "name": "Kubernetes HA",
421
+ "tagline": "Clústeres multirregión, máxima disponibilidad.",
422
+ "features": ["2 regiones", "Conmutación de región", "Autoescalado"],
423
+ "bestFor": "Críticos y globales"
424
+ }
425
+ },
397
426
  "pricing": {
398
427
  "headline1": "Del",
399
428
  "headlineSketch": "boceto",
@@ -394,6 +394,35 @@
394
394
  "description": "Chaque route valide avec Zod, chaque requête utilise le bon client Supabase, chaque table a RLS. Les agents IA reproduisent les mêmes schémas corrects à chaque fois."
395
395
  }
396
396
  },
397
+ "deploy": {
398
+ "headline": "Déployez à votre façon,",
399
+ "headlineHighlight": "en une commande",
400
+ "subheading": "D'un seul VPS à Kubernetes multi-région — choisissez votre niveau d'évolutivité et de résilience. Passez au palier supérieur quand vous le souhaitez.",
401
+ "compose": {
402
+ "name": "Docker Compose",
403
+ "tagline": "Un VPS, toute votre stack incluse.",
404
+ "features": ["1 serveur", "Région unique"],
405
+ "bestFor": "MVP et outils internes"
406
+ },
407
+ "composeHa": {
408
+ "name": "Compose HA",
409
+ "tagline": "Deux serveurs, bascule automatique.",
410
+ "features": ["2 serveurs", "Réplication en flux", "Bascule automatique"],
411
+ "bestFor": "SaaS en production sans Kubernetes"
412
+ },
413
+ "k8s": {
414
+ "name": "Kubernetes",
415
+ "tagline": "Cluster k3s à mise à l'échelle automatique.",
416
+ "features": ["Région unique", "Mise à l'échelle horizontale", "Mises à jour progressives"],
417
+ "bestFor": "Applications à fort trafic"
418
+ },
419
+ "k8sHa": {
420
+ "name": "Kubernetes HA",
421
+ "tagline": "Clusters multi-région, disponibilité maximale.",
422
+ "features": ["2 régions", "Bascule de région", "Mise à l'échelle auto"],
423
+ "bestFor": "Critique et mondial"
424
+ }
425
+ },
397
426
  "pricing": {
398
427
  "headline1": "Du",
399
428
  "headlineSketch": "brouillon",