Skip to main content
Pylon runs as a Rust binary (plus Bun, which runs your TypeScript and SSR) behind a TLS-terminating reverse proxy. This page covers the supported self-hosted deploy shapes and the experimental Workers path. To skip self-hosting, Stack0 Cloud hosts the same binary. Each shape can run your source (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

Optional:
Hard requirements that fail to start:
  • 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 set PYLON_CORS_ORIGIN
  • PYLON_DEV_MODE=true with PYLON_ADMIN_TOKEN unset is refused
  • /api/__test__/reset is 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:
Reverse proxy (Caddy or nginx) forwards :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).
For backups, run 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
Minimum bill: ~$25/mo for a production deployment. Compile Pylon with --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 single sst.config.ts. It uses the same components (Aurora, Fargate, ALB, EFS, CloudFront) and one CLI for deploys, secrets, and per-PR preview environments.
See Deploy with SST for the full walkthrough:
  • 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

Done. See Cloud for full details.

Shape 6: local dev

This starts on port 4321 with PYLON_DEV_MODE=true defaults: Studio at /studio, hot-reload, and permissive CORS. Do not use this mode in production.

Health checks

  • GET /health returns 200 with {"status":"ok","uptime_seconds":N}
  • GET /metrics returns Prometheus text when Accept: text/plain
  • GET /health/ready returns 503 with {"status":"warming"} until the boot warm-up has rendered one page on every function runner, then 200 with {"status":"ready"}
The warm-up renders /, 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:
  1. /health/ready returns 503 with {"status":"draining"}.
  2. The server keeps serving requests for PYLON_SHUTDOWN_DELAY_SECS (default 0).
  3. The server stops accepting requests and waits up to PYLON_DRAIN_SECS (default 10) for in-flight requests and in-memory jobs to finish.
  4. Shards save their state. With several machines, each shard moves to another machine (up to PYLON_SHARD_DRAIN_SECS, default 20).
  5. The server flushes the change log, checkpoints SQLite, and exits.
A second signal stops the process at once. On Windows there is no 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:
  1. Start a new instance.
  2. Wait until its /health/ready returns 200.
  3. Send SIGTERM to one old instance.
  4. Repeat until no old instance is left.
More than one instance needs Postgres. SQLite allows one instance per database file. On a single instance a deploy restarts the process, so requests fail until the new process is ready. An abrupt stop does not corrupt SQLite: WAL mode is crash-consistent.

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_ORIGIN does not affect them.
  • TLS is required on iOS. App Transport Security rejects ws:// and http:// to non-localhost hosts. Always use wss:// and https:// in production. Self-signed certificates work in dev with an ATS exception in Info.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. Configure PYLON_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:
Same env vars apply. Mount the data volume so it survives container restarts. For a self-contained image (no runtime mount) or a docker-compose setup with a Postgres sidecar, run 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.