pylon start app.ts) or a production build (pylon start dist). The production build needs no source tree and no node_modules on the server.
Required environment
PYLON_CORS_ORIGIN=*in non-dev mode is refused- In non-dev mode, the CORS gate refuses to start with no trusted origins. Declare them in
manifest.auth.trustedOrigins, or setPYLON_CORS_ORIGIN PYLON_DEV_MODE=truewithPYLON_ADMIN_TOKENunset is refused/api/__test__/resetis disabled unless dev + in-memory + loopback
Ports
Pylon uses up to four adjacent ports, depending on which transports you enable:
Your reverse proxy must forward all relevant ports. For deployments with a single public port (Fly.io, Cloud Run), path-route the WebSocket, SSE, and shard upgrades through that one port at the proxy (see the Caddy and nginx examples below), then point the client’s sync
wsUrl at the public origin. There is no server-side WS-URL environment variable; the client’s transport config tells it where to connect.
Shape 1: single VPS (SSH + systemd)
This is the simplest and cheapest shape: the pylon binary plus Bun, one systemd unit, and one reverse proxy.pylon deploy --target systemd generates this unit, plus step-by-step install commands, for your app. The shape looks like this:
:443 → :4321 plus WebSocket upgrades for /ws → :4322, /events (SSE) → :4323, and shard WS → :4324. Shard WebSockets are also served at /shard on the main port, and the TypeScript and Swift shard clients use that path by default, so a proxy that forwards WebSocket upgrades on :443 needs no shard rule. /shard needs plain HTTP on :4321 (TLS terminated at the proxy).
The shard WebSocket allows 64 connections per client IP by default (PYLON_SHARD_WS_MAX_PER_IP). On /shard the client IP comes from the trusted proxy headers; on :4324 behind a proxy, every connection comes from the proxy’s address, so set PYLON_SHARD_WS_MAX_PER_IP=0 or a value above your player count. pylon deploy --target systemd emits a matching nginx or Caddy reverse-proxy config alongside the unit file (generated by crates/runtime/src/tls.rs).
pylon backup /var/backups/pylon/$(date +%F) nightly with cron. Test the restore quarterly, using the test at crates/runtime/tests/backup_restore.rs.
Caddy example
Shape 2: AWS ECS + Aurora (Terraform)
deploy/terraform/modules/pylon/ provisions:
- ECS Fargate service (0.25 vCPU, 512 MB) ~$9/mo
- Aurora Serverless v2 (0.5–2 ACU) ~$15/mo minimum
- ALB with TLS + WebSocket routing
- CloudFront CDN + Route53 DNS
--features postgres-live and set DATABASE_URL=postgres://....
Shape 3: AWS via SST (TypeScript IaC)
For TypeScript-first infrastructure, SST v3 provisions the same AWS shape from a singlesst.config.ts. It uses the same components (Aurora, Fargate, ALB, EFS, CloudFront) and one CLI for deploys, secrets, and per-PR preview environments.
- secrets
- custom domains
- multi-port load balancer config
- EFS vs S3 storage
- horizontal scaling
Shape 4: Cloudflare Workers (edge, experimental)
crates/workers/ builds a WASM bundle (worker-build --release) that runs on Workers with a D1 binding. See crates/workers/README.md for current limitations. Idle apps cost $0, because Workers scales to zero. Cost rises with request volume. See Workers costs.
Workers does not yet support realtime shards (tick-based sims). They need persistent state that Workers alone cannot hold efficiently. Use shape 1 or 2 for game shards.
Shape 5: Stack0 Cloud
Shape 6: local dev
PYLON_DEV_MODE=true defaults: Studio at /studio, hot-reload, and permissive CORS. Do not use this mode in production.
Health checks
GET /healthreturns 200 with{"status":"ok","uptime_seconds":N}GET /metricsreturns Prometheus text whenAccept: text/plainGET /health/readyreturns 503 with{"status":"warming"}until the boot warm-up has rendered one page on every function runner, then 200 with{"status":"ready"}
/, or the first page with no parameters. PYLON_SSR_WARM_TIMEOUT_SECS (default 120) limits how long it holds /health/ready at 503. Set it to 0 to turn the warm-up off.
Use /health for liveness and /health/ready to decide when an instance gets traffic.
Shutdown and rolling deploys
SIGTERM or SIGINT starts a graceful shutdown:
/health/readyreturns 503 with{"status":"draining"}.- The server keeps serving requests for
PYLON_SHUTDOWN_DELAY_SECS(default 0). - The server stops accepting requests and waits up to
PYLON_DRAIN_SECS(default 10) for in-flight requests and in-memory jobs to finish. - Shards save their state. With several machines, each shard moves to another machine (up to
PYLON_SHARD_DRAIN_SECS, default 20). - The server flushes the change log, checkpoints SQLite, and exits.
SIGTERM, and the process stops at once.
Set PYLON_SHUTDOWN_DELAY_SECS to at least the time your load balancer takes to mark an instance unhealthy: the number of failed checks it needs, times the check interval, plus the check timeout. For example, 3 failed checks at a 10 s interval with a 5 s timeout need at least 35 s. During the delay the load balancer sees the 503 and stops sending new requests, while requests that are already on their way still get an answer. A platform that removes the instance from the load balancer before it sends SIGTERM (for example, ECS behind an ALB) needs no delay. Give the platform’s kill timeout room for the whole sequence: the delay, plus PYLON_DRAIN_SECS, plus PYLON_SHARD_DRAIN_SECS if you run shards.
For a rolling deploy with more than one instance:
- Start a new instance.
- Wait until its
/health/readyreturns 200. - Send
SIGTERMto one old instance. - Repeat until no old instance is left.
Native clients (iOS, macOS, Android)
Native clients hit the same HTTP and WebSocket endpoints as browsers. Keep these points in mind:- CORS does not apply. Native HTTP clients skip CORS entirely.
PYLON_CORS_ORIGINdoes not affect them. - TLS is required on iOS. App Transport Security rejects
ws://andhttp://to non-localhost hosts. Always usewss://andhttps://in production. Self-signed certificates work in dev with an ATS exception inInfo.plist. - Background sessions. For large file uploads or downloads that should continue when the app is backgrounded, configure
URLSessionConfiguration.background(...)in your transport. See Swift SDK.
Scale-out
Pylon supports multiple processes behind a load balancer. Use Postgres for shared entities, auth state, jobs, workflows, and sync state. ConfigurePYLON_CLUSTER_BUS for live change, presence, and CRDT fanout between self-hosted processes. Stack0 Cloud configures its managed relay for you.
Jobs and workflow steps use at-least-once execution. Make their handlers idempotent. SQLite remains a single-machine backend.
See Horizontal scaling for the full setup and failure model.
For multi-region on Stack0 Cloud, set the workspace region during signup. Self-hosted multi-region is a manual setup: one Pylon process per region, with app-level routing.
What about Docker?
The runtime image bundles the pylon binary plus Bun (which runs your TypeScript functions and SSR). It expects your app source mounted (or baked) at/app and keeps data on the /data volume:
pylon deploy --target docker or pylon deploy --target compose in your project. Both commands generate a Dockerfile that copies your app onto the runtime image and serves it with pylon start.
To ship only the build output, see the multi-stage Dockerfile in Production build.