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:

  • PORT wins. The port precedence is PORT > --port > app.config.ts — deliberately, because platforms assign through PORT. 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_SECRET unset 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/readiness flips 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-runtime traced 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 callable runServe are required; how far the subsequent start gets is reported, not required. If your build turns red at load 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 deploy

The 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-1gb

sync: 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.