Platform recipes
Deploy the same container to Fly.io, Railway, Render, or a Hetzner VM — one image, four wrappers.
Every recipe on this page deploys the same artifact: the production image
from the compose baseline's docker/api.Dockerfile (voltro baseline set compose writes it into your project). The platforms differ only in the wrapper
— how they build it, which env vars they inject, and how they health-check it.
What is verified, stated exactly. The container itself is the tested part:
it builds from a clean context, boots voltro serve from the precompiled serve
bundle, survives the pnpm deploy relocation, and answers
/internal/readiness with 200 — that loop runs in this repo's own validation.
The platform wrapper files below are written against each platform's current
config format and have not been executed against a live account of that
platform; if one drifts from what the platform ships today, the container is
still right and the fix is in the wrapper.
Four properties of the image every platform relies on:
PORTwins. The port precedence isPORT>--port>app.config.ts— deliberately, because platforms assign throughPORT. You never configure a port in the wrapper beyond telling the platform which one the app answers on.- A missing secret refuses to boot.
VOLTRO_SESSION_SECRETunset is a clean, named boot refusal — not a server that signs with a default. Set secrets in the platform's secret store before the first deploy, or read the refusal message; both are correct outcomes. /internal/readinessflips to 200 only after the whole boot. Use it as the health check everywhere; routing traffic on process-up instead of readiness is how a deploy serves 502s for the first seconds.- The build imports the serve bundle before the image is finished, and a
failed import fails the build. The image-build stage has no database and no
secrets, so it cannot require a full boot — but it does not need one: a module
prune-runtimetraced away, a truncated artefact, a wrong entry path or a missing export all fail at import, long before anything connects. So the import and a callablerunServeare required; how far the subsequent start gets is reported, not required. If your build turns red atload gate:, the artefact is wrong and no amount of environment will fix it.
Fly.io
# fly.toml
app = "my-voltro-api"
primary_region = "fra"
[build]
dockerfile = "docker/api.Dockerfile"
build-args = { APP_PATH = "apps/my-app/api" }
[env]
DB_DIALECT = "postgres"
[http_service]
internal_port = 4000
force_https = true
auto_stop_machines = "stop"
auto_start_machines = true
min_machines_running = 0
[[http_service.checks]]
path = "/internal/readiness"
interval = "10s"
timeout = "2s"fly secrets set VOLTRO_SESSION_SECRET=$(openssl rand -base64 32) DB_URL=<from fly postgres attach>
fly deployThe one Fly-specific decision: auto_stop_machines gives you scale-to-zero,
and a cold start pays the container boot. The serve bundle exists for exactly
this — the framework-boot slice of a cold start is ~180–210 ms instead of ~1 s.
Read Scale to zero before choosing
min_machines_running = 0 for an api that owns schedules: a machine that is
never awake fires no cron.
Railway
Railway detects the Dockerfile; point it at the right one and set the build
context to the repo root (the Dockerfile copies the whole workspace for
pnpm install).
// railway.json
{
"build": {
"builder": "DOCKERFILE",
"dockerfilePath": "docker/api.Dockerfile"
},
"deploy": {
"healthcheckPath": "/internal/readiness",
"restartPolicyType": "ON_FAILURE"
}
}Set VOLTRO_SESSION_SECRET and DB_URL as service variables; Railway injects
PORT and the image binds to it — no port config anywhere. The
APP_PATH build arg goes into the service's build settings.
Render
# render.yaml
services:
- type: web
name: my-voltro-api
runtime: docker
dockerfilePath: ./docker/api.Dockerfile
dockerContext: .
healthCheckPath: /internal/readiness
envVars:
- key: DB_DIALECT
value: postgres
- key: VOLTRO_SESSION_SECRET
sync: false
- key: DB_URL
fromDatabase:
name: my-voltro-db
property: connectionString
databases:
- name: my-voltro-db
plan: basic-1gbsync: false makes the secret a dashboard-entered value that never lands in
the blueprint file — the same "we ship no secret values" rule the framework
enforces on its own templates applies to yours.
Hetzner (or any bare VM)
A VM is the compose baseline with a process manager on top — this is the one recipe whose whole stack is the already-validated path from Self-hosting.
# once, on the VM
apt-get install -y docker.io docker-compose-plugin
git clone <your-repo> /srv/app && cd /srv/app
cp .env.example .env # then fill in real values — nothing boots without them
docker compose up -d --build# /etc/systemd/system/voltro.service — survive reboots
[Unit]
Description=voltro stack
Requires=docker.service
After=docker.service
[Service]
Type=oneshot
RemainAfterExit=true
WorkingDirectory=/srv/app
ExecStart=/usr/bin/docker compose up -d
ExecStop=/usr/bin/docker compose down
[Install]
WantedBy=multi-user.target
Caddy (in the baseline compose) terminates TLS with an automatic Let's Encrypt
certificate — point the domain's A record at the VM and the certificate is
provisioned on first request. What a VM does NOT give you: rolling deploys
(compose restarts in place — expect seconds of downtime per deploy, or put two
VMs behind a load balancer), and managed Postgres backups —
voltro data backup plus the restore drill is your baseline,
and the drill is the part people skip.
Which one
| scale-to-zero | managed DB | rolling deploys | cost floor | |
|---|---|---|---|---|
| Fly.io | yes (auto_stop) |
Fly Postgres | yes | ~0 idle |
| Railway | usage-based sleep | built-in | yes | ~0 idle |
| Render | paid plans only | built-in | yes | fixed/instance |
| Hetzner VM | no | bring your own | no (single VM) | fixed, cheapest at steady load |
An api that owns cron schedules should not scale to zero. An api with bursty traffic and no schedules is exactly what scale-to-zero is for. When in doubt, the boring answer — one always-on instance — is also the cheapest to operate.
Why there is no edge-SSR adapter
Every recipe above deploys a container, and that is deliberate: Voltro's SSR is
Node-first (renderToPipeableStream into a Node stream, voltro start as a
long-running Node HTTP server), not a Workers/edge runtime — so there is no
Vercel-/Netlify-edge SSR adapter, and none is planned as a posture. The edge
still gets first-class use where it fits the model: isolated
*.serverless.ts functions
(@voltro/serverless, with Cloudflare / Scaleway / Node adapters) for
request-shaped work at the edge, and
static / ISR pages served from a CDN for
everything that does not need a per-request render. If a page must render per
request, it renders in the container.