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-runThe 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 viagetStaticPaths+ islands),frontend-landing(zero-JS marketing),frontend-spa(client-rendered,--spa), andfrontend-contact(a static page + a serverless form — the page deploys here, the function viavoltro 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): serveindex.htmlfor 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/isrpages — 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:
- Keep it static — give dynamic routes a
getStaticPaths, set pages tostatic/spa. Then the deploy passes. - Ship anyway —
--allow-dynamic. The ssr/isr pages become client-rendered (no server first-paint); fine if they degrade gracefully. - 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 plantells 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 --spaThe 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.cloudThe 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> --spaNetlify 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.