Static-site deploy

Ship the static dist/ from `voltro build` to a cheap CDN (Cloudflare Pages, any S3-compatible bucket, Netlify) instead of serving it off the app server.

voltro build emits a static dist/ — pre-rendered HTML plus content-hashed assets. Serving that off your app server wastes the server on work a CDN does for pennies. voltro static ships dist/ to a cheap external host, off the app server, so you save money at the edge.

voltro static hosts                                          # list supported hosts
voltro static deploy --host cloudflare-pages --project-name my-site --spa
voltro static deploy --host s3 --bucket my-bucket --endpoint https://s3.fr-par.scw.cloud
voltro static deploy --host netlify --site-id <site-id>
voltro static deploy --host cloudflare-pages --project-name my-site --dry-run

The framework owns the policy that's easy to get wrong; the host's official tool does the transfer (so it must be on PATH and authenticated).

Static templates to start from: frontend-static-blog (SSG from a content source via getStaticPaths + islands), frontend-landing (zero-JS marketing), frontend-spa (client-rendered, --spa), and frontend-contact (a static page + a serverless form — the page deploys here, the function via voltro serverless).

Self-hosting already serves dist/

You don't NEED voltro static. When you self-host, voltro start serves dist/ end-to-end (static HTML + SSR/ISR on demand), and your baseline's reverse proxy (Caddy/nginx) can serve the files directly too. So voltro static is a pure cost-offload: push the immutable assets to a cheap CDN while your api + Postgres stay on your own box.

The split works because the browser reaches the self-hosted api over its own URL. Point the web app's api config at the api's public WebSocket endpoint:

// apps/web/app.config.ts
apis: { app: { package: '@app/api', url: 'wss://api.yourdomain.com/ws' } }

The api stays self-hosted (it must — WebSocket subscriptions + Postgres are always-on); only the static bytes move to the edge. POST /rpc forwards the session cookie exactly as the WS path, so SSR loaders + auth resolve identically.

What the framework decides for you

  • Content-Type per file, from its extension (including the ones generic tools get wrong: .wasm, .woff2, .svg, .webmanifest, .avif).
  • Cache-Control: content-hashed assets (assets/index-a1b2c3d4.js) → public, max-age=31536000, immutable; HTML → revalidate on every request, so a redeploy is visible immediately.
  • SPA fallback (--spa): serve index.html for unknown routes — for a single-page app. Leave it off for per-route SSG output.

For hosts that read them (Cloudflare Pages, Netlify) this is emitted as _headers / _redirects. If you already ship your own _headers or _redirects in dist/, the framework leaves them untouched — your intent wins.

The render-mode gate

A pure static CDN can only serve pre-rendered HTML. So before it uploads, voltro static deploy checks the app's render-mode profile and blocks if the app isn't static-safe:

  • ssr / isr pages — these need a runtime to render per request; a CDN can't run them.
  • dynamic routes without getStaticPaths — the build emits no artifact for them, so they'd be 404 on a CDN.
voltro static deploy --host cloudflare-pages --project-name my-site
# → voltro static deploy: "." is NOT pure-static —
#     • 1 ssr page(s): need a runtime; a CDN can't render them
#     → Serve those on a runtime (`voltro start` / a container), OR add
#       getStaticPaths / set the pages to `static` / `spa`. To ship anyway
#       (ssr/isr become client-rendered), pass --allow-dynamic.

Three ways forward:

  1. Keep it static — give dynamic routes a getStaticPaths, set pages to static / spa. Then the deploy passes.
  2. Ship anyway--allow-dynamic. The ssr/isr pages become client-rendered (no server first-paint); fine if they degrade gracefully.
  3. Use a runtime — if the app genuinely needs SSR/ISR, it belongs on voltro start / a container, not a pure CDN. See self-hosting. voltro deploy plan tells you, per app, which tier it belongs to.

voltro build records the profile to dist/.voltro-build.json, so the gate (and the cloud control-plane) classify without re-scanning. Point the scan at a non-default app root with --app <dir>.

Hosts

Host --host Mechanism Auth
Cloudflare Pages cloudflare-pages wrangler pages deploy (built-in hash dedup) CLOUDFLARE_API_TOKEN + CLOUDFLARE_ACCOUNT_ID
S3-compatible s3 aws s3 sync (two-pass cache) AWS_ACCESS_KEY_ID + AWS_SECRET_ACCESS_KEY
Netlify netlify netlify deploy (SHA1 digest deploy) NETLIFY_AUTH_TOKEN

Cloudflare Pages

# one-time: wrangler pages project create my-site
voltro static deploy --host cloudflare-pages --project-name my-site --spa

The project must exist first. Uploads are incremental (wrangler dedups by hash); new deploys auto-invalidate the edge.

S3-compatible (AWS, Scaleway, R2, B2, MinIO)

One code path for every S3-compatible bucket — only the endpoint and keys change. Omit --endpoint for AWS; set it for the others.

# Scaleway Object Storage (has native static-website hosting)
AWS_ACCESS_KEY_ID=<scw-access> AWS_SECRET_ACCESS_KEY=<scw-secret> \
  voltro static deploy --host s3 --bucket my-bucket \
    --endpoint https://s3.fr-par.scw.cloud \
    --website-url https://my-bucket.s3-website.fr-par.scw.cloud

The sync runs two passes: immutable hashed assets first, then HTML with no-cache, and --delete prunes removed files. SPA fallback for a raw bucket is bucket-website / CDN configuration (and R2/B2/MinIO need a router in front entirely) — --spa warns rather than silently doing nothing here.

Netlify

voltro static deploy --host netlify --site-id <site-id> --spa

Netlify infers content types server-side and its digest-based deploy is incremental and atomic.

CI

All three host CLIs run headless with a token in the environment — drop the deploy command into your pipeline after voltro build. Use --dry-run to print the exact plan in a PR check without uploading.