PaaS
For when you want shipping speed over operational control. Three popular options covered below. All share the same shape:
- Push your Dockerfile.
- Configure env vars / secrets.
- Provision managed Postgres + Redis from the same provider.
- Done.
Fly.io
fly.toml:
app = "platform"
primary_region = "fra"
[build]
dockerfile = "Dockerfile"
[env]
NODE_ENV = "production"
HOME = "/var/lib/omnitron" # daemon state → $HOME/.omnitron (no OMNITRON_HOME var)
[mounts]
source = "omnitron_data"
destination = "/var/lib/omnitron"
[http_service]
internal_port = 3001 # public app (Titan HTTP transport)
force_https = true
auto_stop_machines = false
auto_start_machines = true
min_machines_running = 1
# The app transport serves /health (JSON {status:"online"}); use that for the
# HTTP check below. There is no /healthz on this port — the daemon's /healthz
# lives on port 9803, bound to loopback by default.
[[http_service.checks]]
method = "GET"
path = "/health"
interval = "15s"
timeout = "5s"
[[vm]]
cpu_kind = "shared"
cpus = 2
memory_mb = 2048
# Provision DB:
fly postgres create --name platform-pg --region fra
fly postgres attach platform-pg
# Provision Redis:
fly redis create --name platform-redis --region fra
fly redis attach platform-redis
# Secrets:
fly secrets set JWT_SECRET=$(openssl rand -hex 48)
# Deploy:
fly deploy
Fly auto-injects DATABASE_URL and REDIS_URL env vars from
the attached resources. Persistent volume preserves
~/.omnitron/ across deploys.
For multi-region:
fly scale count 3 --region fra,ams,lhr # 3 machines, one per region
Each region's machine sees the local Postgres/Redis (Fly
replicates them). For Omnitron cluster mode, set cluster.peers
to the Fly internal DNS names.
Railway
railway.json:
{
"$schema": "https://railway.app/railway.schema.json",
"build": { "builder": "DOCKERFILE" },
"deploy": {
"startCommand": "pnpm omnitron up --foreground",
"healthcheckPath": "/health",
"healthcheckTimeout": 30,
"restartPolicyType": "ON_FAILURE",
"restartPolicyMaxRetries": 10
}
}
Railway probes the published port, so healthcheckPath must hit
a route on the app's HTTP transport — that's /health (JSON
{"status":"online"}), not /healthz. The /healthz route only
exists on the daemon's separate metrics port (9803).
Workflow:
- Push the repo to GitHub.
railway upor connect via dashboard.- Add Postgres + Redis from Railway templates (one click each).
- Set
JWT_SECRETvia the dashboard. - Railway auto-detects the Dockerfile and deploys.
Env vars (auto-injected):
DATABASE_URL— from the linked PostgresREDIS_URL— from the linked RedisPORT— Railway-assigned. Omnitron doesn't readPORT; each process declares an explicit port in itstransports.http.port(e.g.3001). Pin the published port to that, or template the port into the process config from an env var you set yourself.
For persistent volumes (Omnitron state), add a Volume in the
dashboard, mount it at /var/lib/omnitron, and set HOME to the
same path so the daemon writes its ~/.omnitron state there.
Render
render.yaml:
services:
- type: web
name: platform
runtime: docker
dockerfilePath: ./Dockerfile
plan: standard
region: oregon
autoDeploy: true
healthCheckPath: /health # app transport route; /healthz is daemon-only (port 9803)
envVars:
- { key: NODE_ENV, value: production }
- { key: HOME, value: /var/lib/omnitron } # daemon state → $HOME/.omnitron
- { key: JWT_SECRET, generateValue: true }
- { fromDatabase: { name: platform-pg, property: connectionString }, key: DATABASE_URL }
- { fromService: { name: platform-redis, type: redis, property: connectionString }, key: REDIS_URL }
disk:
name: omnitron-state
mountPath: /var/lib/omnitron
sizeGB: 5
databases:
- name: platform-pg
plan: standard
region: oregon
- type: redis
name: platform-redis
plan: standard
region: oregon
# Or just connect the repo via the dashboard — render.yaml is auto-detected.
Render handles the rest. generateValue: true mints a JWT
secret on first deploy.
Common patterns across PaaS
Boot command
Always:
pnpm omnitron up --foreground
--foreground keeps Node attached so the PaaS supervisor sees a
running process; the daemon takes over signal handling.
Health check path
For a check against the published app port, use /health —
the Titan HTTP transport returns 200 {"status":"online"}
there. The Prometheus-style /healthz (plain 200 ok) lives
only on the daemon's metrics port (9803); target it with a
port-aware check (Fly [checks]) if your PaaS allows one.
Neither /readyz nor an HTTP readiness route exists out of the
box — see the k8s note for
adding one via customRoutes.
Persistent volume
Mount a volume at, e.g., /var/lib/omnitron and set HOME to
the same path so the daemon's ~/.omnitron (PID, SQLite state,
secrets store) persists. Without it, every deploy is a fresh
daemon — fine for stateless workloads, loses uptime history +
secrets on restart.
Secrets
Always via the PaaS secret manager. Never in omnitron.config.ts
or config/default.json.
Database migrations
Most PaaS support a "pre-deploy" or "release" command:
# Fly.io fly.toml
[deploy]
release_command = "pnpm omnitron infra migrate"
# Railway — separate one-off command
# Render — pre-deploy script in render.yaml
Runs once per deploy, before the new image takes traffic.
Scaling
| PaaS | How |
|---|---|
| Fly | fly scale count N --region X |
| Railway | Dashboard slider (replicas) |
| Render | Plan-based; auto-scaling on paid plans |
PaaS scaling is horizontal (more pods). Omnitron-level
scaling (worker pools within a pod) is configured per process
in defineSystem — set instances on a process and give it a
scaling block ({ strategy, maxInstances, targetCPU, ... }).
There is no app-level scaling block.
Logs
Each PaaS aggregates container stdout. For production:
- Fly —
fly logsfor live tail; ship to Datadog/Logtail. - Railway — Dashboard live tail; webhook to external log aggregator.
- Render — Dashboard live tail; log streams to S3/SIEM.
Set logging.level to 'info' in production; 'debug' only
for time-bounded investigations.
Multi-app on one PaaS service
A single Omnitron daemon can supervise multiple Titan apps in
one container. Configure in omnitron.config.ts:
export default defineEcosystem({
apps: [
{ name: 'api', bootstrap: './apps/api/dist/bootstrap.js' },
{ name: 'worker', bootstrap: './apps/worker/dist/bootstrap.js' },
{ name: 'ws', bootstrap: './apps/ws/dist/bootstrap.js' },
],
// ...
});
Then expose ports for each via PaaS multi-port config (Fly) or multiple services (Railway).
Single-container deployment is cheaper but losses isolation — all apps share the container's resources.
PaaS limitations
- Filesystem ephemerality — without explicit volumes,
~/.omnitron/is reset on every deploy. Volumes are mandatory for production. - PostgreSQL hosting — managed Postgres on each PaaS is fine for hundreds of req/s. For TB-scale data, move to AWS RDS / GCP Cloud SQL.
- Compute limits — PaaS billing tiers cap CPU / RAM. Heavy workloads outgrow them.
- Cluster mode — Omnitron's cluster + fleet patterns assume long-lived daemons with known addresses. PaaS often reschedules containers on different hosts. Use single-node daemon per region with managed Postgres / Redis instead.
For more control, see Kubernetes or Docker Compose on a VM.
Cost rough-order
Roughly per month for a small production setup:
| PaaS | Compute + DB + Redis | Total |
|---|---|---|
| Fly | shared CPU, 2 GB + small DB + small Redis | $30–60 |
| Railway | starter tier + DB + Redis | $20–50 |
| Render | standard plan + DB + Redis | $50–100 |
Numbers vary widely by traffic. PaaS shines for $0–$300/mo range; beyond that, Kubernetes catches up on cost.
See also
- Docker Compose — single-host alternative
- Kubernetes — when you outgrow PaaS
- Bare-metal — own-server option
- Configuration