vibecarbon 0.4.0 → 0.5.1

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.
@@ -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",
@@ -394,6 +394,35 @@
394
394
  "description": "Cada rota valida com Zod, cada consulta usa o cliente Supabase correto, cada tabela tem RLS. Os agentes de IA replicam sempre os mesmos padrões corretos."
395
395
  }
396
396
  },
397
+ "deploy": {
398
+ "headline": "Implante do seu jeito,",
399
+ "headlineHighlight": "com um único comando",
400
+ "subheading": "De um único VPS a Kubernetes multirregião — escolha seu nível de escala e resiliência. Suba para o próximo nível quando quiser.",
401
+ "compose": {
402
+ "name": "Docker Compose",
403
+ "tagline": "Um VPS, com toda a sua stack incluída.",
404
+ "features": ["1 servidor", "Região única"],
405
+ "bestFor": "MVPs e ferramentas internas"
406
+ },
407
+ "composeHa": {
408
+ "name": "Compose HA",
409
+ "tagline": "Dois servidores, failover automático.",
410
+ "features": ["2 servidores", "Replicação em streaming", "Failover automático"],
411
+ "bestFor": "SaaS em produção sem Kubernetes"
412
+ },
413
+ "k8s": {
414
+ "name": "Kubernetes",
415
+ "tagline": "Cluster k3s com escalonamento automático.",
416
+ "features": ["Região única", "Escalonamento horizontal", "Atualizações graduais"],
417
+ "bestFor": "Apps de alto tráfego"
418
+ },
419
+ "k8sHa": {
420
+ "name": "Kubernetes HA",
421
+ "tagline": "Clusters multirregião, disponibilidade máxima.",
422
+ "features": ["2 regiões", "Failover de região", "Escalonamento automático"],
423
+ "bestFor": "Crítico e global"
424
+ }
425
+ },
397
426
  "pricing": {
398
427
  "headline1": "Do",
399
428
  "headlineSketch": "esboço",
@@ -16,6 +16,7 @@ import { AIIntegrationSection } from '../components/AIIntegrationSection';
16
16
  import { ArchitectureSection } from '../components/ArchitectureSection';
17
17
  import { ComparisonSection } from '../components/ComparisonSection';
18
18
  import { CTAFooter } from '../components/CTAFooter';
19
+ import { DeploymentScenariosSection } from '../components/DeploymentScenariosSection';
19
20
  import { FilmGrainOverlay } from '../components/effects/FilmGrainOverlay';
20
21
  import { VGlowEffect } from '../components/effects/VGlowEffect';
21
22
  import { FAQSection } from '../components/FAQSection';
@@ -168,6 +169,10 @@ export default function VibecarbonHome() {
168
169
  <ArchitectureSection />
169
170
  </ScrollSection>
170
171
 
172
+ <ScrollSection id="deploy-scenarios">
173
+ <DeploymentScenariosSection />
174
+ </ScrollSection>
175
+
171
176
  <ScrollSection id="pricing">
172
177
  <PricingSection />
173
178
  </ScrollSection>
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "vibecarbon",
3
- "version": "0.4.0",
3
+ "version": "0.5.1",
4
4
  "description": "Create and manage production-ready Vibecarbon applications",
5
5
  "type": "module",
6
6
  "bin": "./src/cli.js",
@@ -57,12 +57,12 @@
57
57
  "url": "https://github.com/hyperformant/vibecarbon.git"
58
58
  },
59
59
  "dependencies": {
60
- "@aws-sdk/client-s3": "^3.1017.0",
61
- "@clack/prompts": "^1.1.0",
62
- "@pulumi/hcloud": "^1.32.2",
63
- "@pulumi/pulumi": "^3.231.0",
60
+ "@aws-sdk/client-s3": "^3.1058.0",
61
+ "@clack/prompts": "^1.5.0",
62
+ "@pulumi/hcloud": "^1.38.0",
63
+ "@pulumi/pulumi": "^3.244.0",
64
64
  "bcryptjs": "^3.0.3",
65
- "undici": "^8.1.0",
65
+ "undici": "^8.3.0",
66
66
  "which": "^7.0.0"
67
67
  },
68
68
  "pnpm": {
@@ -81,18 +81,18 @@
81
81
  }
82
82
  },
83
83
  "devDependencies": {
84
- "@biomejs/biome": "^2.4.9",
84
+ "@biomejs/biome": "^2.4.16",
85
85
  "@semantic-release/changelog": "^6.0.3",
86
86
  "@semantic-release/git": "^10.0.1",
87
87
  "@types/better-sqlite3": "^7.6.13",
88
- "@vitest/coverage-v8": "^4.1.1",
88
+ "@vitest/coverage-v8": "^4.1.8",
89
89
  "autocannon": "^8.0.0",
90
- "better-sqlite3": "^12.8.0",
90
+ "better-sqlite3": "^12.10.0",
91
91
  "conventional-changelog-conventionalcommits": "^9.3.1",
92
92
  "semantic-release": "^25.0.3",
93
- "tsx": "^4.21.0",
94
- "vite": "^8.0.14",
95
- "vitest": "^4.1.1",
96
- "zod": "^4.3.6"
93
+ "tsx": "^4.22.4",
94
+ "vite": "^8.0.16",
95
+ "vitest": "^4.1.8",
96
+ "zod": "^4.4.3"
97
97
  }
98
98
  }
package/src/backup.js CHANGED
@@ -448,6 +448,7 @@ export async function run(args) {
448
448
  sshKeyPath,
449
449
  projectName,
450
450
  envName,
451
+ backupRetain: envConfig.backup?.retentionDays,
451
452
  yes: !!values.y,
452
453
  });
453
454
  tracker.finish();
@@ -647,6 +648,7 @@ async function runCreate({
647
648
  sshKeyPath,
648
649
  projectName,
649
650
  envName,
651
+ backupRetain,
650
652
  yes,
651
653
  }) {
652
654
  if (!yes) {
@@ -660,37 +662,16 @@ async function runCreate({
660
662
  }
661
663
 
662
664
  if (isCompose) {
663
- s.start('Creating backup via Docker Compose...');
665
+ s.start('Creating wal-g base backup...');
664
666
  try {
665
- const { backupCompose, uploadComposeBackupToS3 } = await import(
666
- './lib/deploy/compose/index.js'
667
- );
668
- const backupFile = backupCompose(serverIp, sshKeyPath, projectName);
669
- s.stop('Backup created');
670
- p.log.success(`Backup: ${c.bold(backupFile)}`);
671
- p.log.info(`Location: /opt/${projectName}/backups/${backupFile}`);
672
-
673
- if (useS3) {
674
- s.start('Uploading backup to S3...');
675
- try {
676
- await uploadComposeBackupToS3(serverIp, sshKeyPath, projectName, backupFile, s3Config);
677
- s.stop(`Uploaded to S3: ${s3Config.bucket}/backups/${backupFile}`);
678
- } catch (uploadError) {
679
- s.stop(`S3 upload failed: ${uploadError.message}`);
680
- p.log.warn('Backup is available on the server but was not uploaded to S3.');
681
- // Dump the raw error to stderr so automation captures the
682
- // actual AWS SDK error code + HTTP status — the spinner
683
- // message above hides the detail e2e/CI needs.
684
- console.error(
685
- `[backup] S3 upload failed — name=${uploadError.name} code=${uploadError.Code ?? uploadError.code ?? 'n/a'} status=${uploadError.$metadata?.httpStatusCode ?? 'n/a'} bucket=${s3Config.bucket} endpoint=${s3Config.endpoint} region=${s3Config.region}`,
686
- );
687
- if (uploadError.stack) {
688
- console.error(uploadError.stack);
689
- }
690
- }
691
- } else {
692
- p.log.info(`Download: scp root@${serverIp}:/opt/${projectName}/backups/${backupFile} .`);
693
- }
667
+ const { backupCompose } = await import('./lib/deploy/compose/index.js');
668
+ backupCompose(serverIp, sshKeyPath, projectName, { retain: backupRetain });
669
+ // "uploaded to S3" wording matches the k8s path (line ~689) and the e2e
670
+ // backup-step verification (which greps /uploaded to s3/i to confirm the
671
+ // backup is durable before allowing destroy → restore).
672
+ s.stop('wal-g base backup uploaded to S3');
673
+ p.log.success('Backup uploaded to S3 — wal-g pushed the base backup directly.');
674
+ p.log.info(`List backups: ${c.info(`vibecarbon backup ${envName} -l`)}`);
694
675
  } catch (error) {
695
676
  s.stop('Backup failed');
696
677
  p.log.error(error.message);
package/src/destroy.js CHANGED
@@ -1055,49 +1055,11 @@ ${c.danger('WARNING: All data on these servers will be permanently lost!')}
1055
1055
  backupSpinner.start('Creating backup...');
1056
1056
  try {
1057
1057
  if (envConfig.deployMode === 'compose' || envConfig.deployMode === 'compose-ha') {
1058
- const { backupCompose, uploadComposeBackupToS3 } = await import(
1059
- './lib/deploy/compose/index.js'
1060
- );
1061
- const backupFile = backupCompose(serverIp, sshKeyPath, projectConfig.projectName);
1062
-
1063
- const backupS3 = envConfig.backupS3;
1064
- if (backupS3) {
1065
- const { getS3Credentials } = await import('./lib/hetzner-guided-setup.js');
1066
- const creds = await getS3Credentials(projectConfig.projectName, { save: false });
1067
- if (creds) {
1068
- await uploadComposeBackupToS3(
1069
- serverIp,
1070
- sshKeyPath,
1071
- projectConfig.projectName,
1072
- backupFile,
1073
- {
1074
- ...backupS3,
1075
- accessKey: creds.accessKey,
1076
- secretKey: creds.secretKey,
1077
- },
1078
- );
1079
- backupSpinner.stop(`Backup saved to S3: ${backupS3.bucket}/backups/${backupFile}`);
1080
- } else {
1081
- backupSpinner.stop(`Backup created on server: ${backupFile}`);
1082
- p.log.warn(
1083
- 'Could not upload to S3 (no credentials). Download before destroy completes:',
1084
- );
1085
- p.log.info(
1086
- ` scp root@${serverIp}:/opt/${projectConfig.projectName}/backups/${backupFile} .`,
1087
- );
1088
- }
1089
- } else {
1090
- // No S3 — download locally
1091
- const { scpDownload } = await import('./lib/ssh.js');
1092
- const localPath = join(cwd, backupFile);
1093
- scpDownload(
1094
- serverIp,
1095
- sshKeyPath,
1096
- `/opt/${projectConfig.projectName}/backups/${backupFile}`,
1097
- localPath,
1098
- );
1099
- backupSpinner.stop(`Backup saved locally: ${backupFile}`);
1100
- }
1058
+ const { backupCompose } = await import('./lib/deploy/compose/index.js');
1059
+ backupCompose(serverIp, sshKeyPath, projectConfig.projectName, {
1060
+ retain: envConfig.backup?.retentionDays,
1061
+ });
1062
+ backupSpinner.stop('wal-g base backup pushed to S3');
1101
1063
  }
1102
1064
  } catch (backupError) {
1103
1065
  backupSpinner.stop(`Backup failed: ${backupError.message}`);
@@ -13,13 +13,21 @@ import { DNS01_OVERRIDE_FILE, dnsChallengeEnv } from './acme.js';
13
13
  /**
14
14
  * Render the reconcile.sh script
15
15
  */
16
- function renderReconcileScript(projectName, composeFlags, isFast = false) {
16
+ export function renderReconcileScript(projectName, composeFlags, isFast = false) {
17
+ // On compose-ha servers the replication overlay (docker-compose.replication.yml)
18
+ // publishes port 5433 → db:5432 for pg_basebackup / streaming replication.
19
+ // reconcile.sh is baked with static composeFlags at deploy time, so we embed a
20
+ // shell conditional here that appends the overlay at runtime when present.
21
+ // On single-server compose the file never exists, so this is a safe no-op.
22
+ const replOverlayFlag =
23
+ "$([ -f docker-compose.replication.yml ] && echo '-f docker-compose.replication.yml')";
24
+
17
25
  const pullStep = isFast
18
26
  ? ''
19
27
  : `
20
28
  # 1. Pull latest images if needed
21
29
  echo "Pulling images..."
22
- docker compose ${composeFlags} pull --quiet --policy missing --ignore-buildable || true
30
+ docker compose ${composeFlags} ${replOverlayFlag} pull --quiet --policy missing --ignore-buildable || true
23
31
  `;
24
32
 
25
33
  return `#!/bin/bash
@@ -47,7 +55,7 @@ fi
47
55
  ${pullStep}
48
56
  # 2. Re-create containers if state differs
49
57
  echo "Updating containers..."
50
- docker compose ${composeFlags} up -d --remove-orphans
58
+ docker compose ${composeFlags} ${replOverlayFlag} up -d --remove-orphans
51
59
 
52
60
  # 3. Ensure systemd service is active (if applicable)
53
61
  if [ -f "/etc/systemd/system/${projectName}.service" ]; then
@@ -107,6 +115,90 @@ function composeFileFlags(options = {}) {
107
115
  return files.join(' ');
108
116
  }
109
117
 
118
+ /**
119
+ * Render the Traefik dynamic-config file that instructs Traefik to obtain and
120
+ * serve ONE cert for the entire deploy as the TLS default store entry.
121
+ *
122
+ * With `defaultGeneratedCert`, Traefik proactively requests a single cert via
123
+ * the named resolver at startup and serves it by default for every TLS router
124
+ * that does NOT carry an explicit `tls.certresolver` label. All our production
125
+ * routers use `tls=true` without a resolver — they all get this cert via SNI.
126
+ *
127
+ * Challenge-type differences:
128
+ * - DNS-01 (managed DNS — cloudflare/hetzner): `sans: ["*.${domain}"]`.
129
+ * One wildcard cert covers all subdomains, no per-subdomain ACME orders.
130
+ * - HTTP-01 (manual DNS): wildcards require DNS-01 (LE policy), so we list
131
+ * the explicit subdomains that are active for this deploy instead.
132
+ * `main` stays `${domain}` (the app root); `sans` lists api/studio/dashboard
133
+ * plus any enabled feature subdomains (grafana/prometheus, n8n, metabase).
134
+ *
135
+ * The file is written into `volumes/traefik/` which is already bind-mounted
136
+ * as `/etc/traefik/dynamic:ro`. Traefik hot-reloads files from that dir.
137
+ * Multiple files in the dir coexist fine; this file adds only a top-level
138
+ * `tls:` key that has no overlap with the `http:` keys in middlewares.yml.
139
+ *
140
+ * @param {{domain: string, dnsChallenge: boolean, features?: {observability?: boolean, n8n?: boolean, metabase?: boolean}}} opts
141
+ * @returns {string} YAML string suitable for writing to `volumes/traefik/tls-default.yml`
142
+ */
143
+ export function renderTraefikDefaultCert({ domain, dnsChallenge, features = {} }) {
144
+ // The default store cert covers the SUBDOMAINS only. The app at the bare apex
145
+ // (`${domain}`) gets its OWN single-domain cert via the app router's
146
+ // `tls.certresolver` label — it is deliberately NOT in this cert.
147
+ //
148
+ // Why split apex off the wildcard: a cert for [`${domain}`, `*.${domain}`] has
149
+ // TWO ACME authorizations that BOTH validate via the SAME TXT record
150
+ // `_acme-challenge.${domain}`, so both challenge values must be present there
151
+ // simultaneously. The Hetzner lego DNS provider REPLACES the TXT rrset instead
152
+ // of holding both values, so one authorization always fails with
153
+ // "403 unauthorized :: Incorrect TXT record". (Cloudflare holds both and
154
+ // worked — RCA 2026-06-01, compose e2e wildcard verify-deploy.) As two
155
+ // SEPARATE single-authz certs (apex via the app router, `*.${domain}` here),
156
+ // Traefik obtains them serially, so `_acme-challenge.${domain}` only ever
157
+ // needs one value at a time — robust on Hetzner AND Cloudflare.
158
+ let main;
159
+ let sans;
160
+ if (dnsChallenge) {
161
+ // DNS-01: one wildcard covers every subdomain (most general level).
162
+ main = `*.${domain}`;
163
+ sans = [];
164
+ } else {
165
+ // HTTP-01: wildcards require DNS-01 (LE policy), so enumerate the active
166
+ // subdomains. `api` (kong), `studio`, `dashboard` always exist; the rest are
167
+ // feature-gated. The apex app is covered by its own router cert, not here.
168
+ const subs = ['studio', 'dashboard'];
169
+ if (features.observability) subs.push('grafana', 'prometheus');
170
+ if (features.n8n) subs.push('n8n');
171
+ if (features.metabase) subs.push('metabase');
172
+ main = `api.${domain}`;
173
+ sans = subs.map((s) => `${s}.${domain}`);
174
+ }
175
+
176
+ const lines = [
177
+ 'tls:',
178
+ ' stores:',
179
+ ' default:',
180
+ ' defaultGeneratedCert:',
181
+ ' resolver: letsencrypt',
182
+ ' domain:',
183
+ ` main: "${main}"`,
184
+ ];
185
+ if (sans.length) {
186
+ lines.push(' sans:');
187
+ for (const d of sans) lines.push(` - "${d}"`);
188
+ }
189
+
190
+ return `# Traefik default TLS certificate — generated by Vibecarbon at deploy time.
191
+ #
192
+ # Serves as the default cert for ALL TLS routers carrying \`tls=true\` without an
193
+ # explicit certresolver (SNI fallback). Covers the SUBDOMAINS; the apex app gets
194
+ # its own cert via the app router's \`tls.certresolver\`.
195
+ #
196
+ # DNS-01 (cloudflare/hetzner): one wildcard cert covering every subdomain.
197
+ # HTTP-01 (manual DNS): explicit subdomain SANs (LE policy forbids wildcards).
198
+ ${lines.join('\n')}
199
+ `;
200
+ }
201
+
110
202
  /**
111
203
  * Render the deployment bundle to a local temp directory
112
204
  * @param {string} projectName
@@ -233,13 +325,36 @@ export function renderBundle(projectName, options = {}) {
233
325
  // template ships a stub at functions/main/index.ts. RCA: prod-1 2026-05-26.
234
326
  copyDirIfExist('functions', 'functions');
235
327
 
236
- // volumes/traefik/middlewares.yml
328
+ // volumes/traefik/ — dynamic-config directory mounted into Traefik.
329
+ // Always ensure the dir exists in the bundle so Traefik's file provider
330
+ // has a valid mount target even if the source dir is absent.
331
+ mkdirSync(join(stageDir, 'volumes', 'traefik'), { recursive: true });
332
+
237
333
  const traefikMiddlewares = join(cwd, 'volumes', 'traefik', 'middlewares.yml');
238
334
  if (existsSync(traefikMiddlewares)) {
239
- mkdirSync(join(stageDir, 'volumes', 'traefik'), { recursive: true });
240
335
  cpSync(traefikMiddlewares, join(stageDir, 'volumes', 'traefik', 'middlewares.yml'));
241
336
  }
242
337
 
338
+ // Render tls-default.yml only when a domain is known (production deploys).
339
+ // This instructs Traefik to proactively obtain ONE cert for the deploy and
340
+ // serve it as the default for all TLS routers via SNI. DNS-01 deploys get
341
+ // a wildcard SAN (*.${domain}); HTTP-01 deploys get explicit subdomain SANs.
342
+ // The file coexists with middlewares.yml — both live under the same `tls:`
343
+ // / `http:` top-level keys respectively; Traefik merges all files in the dir.
344
+ if (options.domain) {
345
+ const tlsDefaultYaml = renderTraefikDefaultCert({
346
+ domain: options.domain,
347
+ dnsChallenge: Boolean(options.dnsChallenge),
348
+ features: {
349
+ observability: options.observability,
350
+ n8n: options.n8n,
351
+ metabase: options.metabase,
352
+ },
353
+ });
354
+ writeFileSync(join(stageDir, 'volumes', 'traefik', 'tls-default.yml'), tlsDefaultYaml);
355
+ if (options.verbose) console.log('[bundle] Rendered volumes/traefik/tls-default.yml');
356
+ }
357
+
243
358
  copyDirIfExist('backup', 'backup');
244
359
  if (options.n8n) copyDirIfExist('volumes/n8n', 'volumes/n8n');
245
360
  if (options.metabase) copyDirIfExist('volumes/metabase', 'volumes/metabase');