> ## Documentation Index
> Fetch the complete documentation index at: https://docs.pylonsync.com/llms.txt
> Use this file to discover all available pages before exploring further.

# Deploy

> Production deployment shapes — single VPS, AWS ECS, Cloudflare Workers, or Pylon Cloud.

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 plus the experimental Workers path. If you'd rather not run any of this yourself, [Pylon Cloud](/cloud) hosts the same binary.

## Required environment

```sh theme={null}
# Core
PYLON_PORT=4321
PYLON_DB_PATH=/var/lib/pylon/pylon.db
PYLON_FILES_DIR=/var/lib/pylon/uploads
PYLON_MANIFEST=/etc/pylon/pylon.manifest.json

# Auth (MUST be set in non-dev)
PYLON_ADMIN_TOKEN=<64+ random bytes, hex>
PYLON_SESSION_DB=/var/lib/pylon/sessions.db

# Client-facing — declarative: prefer manifest.auth.trustedOrigins
# in app.ts (one source feeds CORS + CSRF + OAuth-redirect). These
# env vars exist as per-gate overrides when ops need to split.
# PYLON_CORS_ORIGIN=https://your-app.com
# PYLON_CSRF_ORIGINS=https://your-app.com

# Mode switch (default is false — only flip in dev)
# PYLON_DEV_MODE=false
```

Optional:

```sh theme={null}
PYLON_JOBS_DB=/var/lib/pylon/jobs.db          # durable job queue
PYLON_OAUTH_GOOGLE_CLIENT_ID=...              # enable Google OAuth
PYLON_OAUTH_GITHUB_CLIENT_ID=...              # enable GitHub OAuth
PYLON_EMAIL_PROVIDER=stack0                   # or sendgrid | resend | webhook
PYLON_EMAIL_API_KEY=sk_live_...               # provider API key
PYLON_EMAIL_FROM=noreply@yourdomain.com       # verified sender
PYLON_EMAIL_ENDPOINT=...                      # only when PYLON_EMAIL_PROVIDER=webhook

# File storage (defaults to local disk)
PYLON_FILES_PROVIDER=stack0                   # local (default) | stack0 | s3
PYLON_STACK0_API_KEY=sk_live_...              # required when provider=stack0
PYLON_STACK0_PROJECT_SLUG=your-project        # required when provider=stack0
PYLON_STACK0_FOLDER=uploads                   # optional folder/prefix
PYLON_FILES_DIR=/var/lib/pylon/uploads        # local provider only
# S3-compatible (AWS S3, R2, Tigris, MinIO). Boot-validated; never silently
# falls back to local disk. Credentials from env only (no IAM-role discovery).
PYLON_S3_BUCKET=my-bucket                     # required when provider=s3
PYLON_S3_ACCESS_KEY=AKIA...                   # required when provider=s3
PYLON_S3_SECRET_KEY=...                       # required when provider=s3
PYLON_S3_REGION=us-east-1                     # optional, default us-east-1
PYLON_S3_ENDPOINT=https://...                 # optional; R2/Tigris/MinIO (path-style)
PYLON_S3_PUBLIC_URL=https://cdn...            # optional; public bucket/CDN base
PYLON_S3_SESSION_TOKEN=...                    # optional; STS/temporary credentials
PYLON_FILES_URL_PREFIX=/api/files             # local provider only

# Cookie auth (browser apps)
PYLON_COOKIE_DOMAIN=.your-app.com
PYLON_COOKIE_SAME_SITE=lax
PYLON_COOKIE_SECURE=true
```

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

| Port                          | Purpose         | When                                                   |
| ----------------------------- | --------------- | ------------------------------------------------------ |
| `PYLON_PORT` (default `4321`) | HTTP            | Always                                                 |
| `PYLON_PORT + 1`              | WebSocket       | When sync engine uses `transport: websocket` (default) |
| `PYLON_PORT + 2`              | SSE (`/events`) | When clients use `transport: sse` fallback             |
| `PYLON_PORT + 3`              | Realtime shards | When apps use `useShard` / `PylonRealtime`             |

Your reverse proxy needs to forward all relevant ports. For deployments with a single public port (Fly.io, Cloud Run), path-route the WebSocket/SSE/shard upgrades through that one port at the proxy (see the Caddy/nginx examples below) and point the client's sync `wsUrl` at the public origin — there's no server-side WS-URL env var; the client is told where to connect via its transport config.

## Shape 1: single VPS (SSH + systemd)

Simplest and cheapest. The pylon binary plus Bun, one systemd unit, one reverse proxy. `pylon deploy --target systemd` generates this unit (plus step-by-step install commands) for your app; the shape looks like:

```ini theme={null}
# /etc/systemd/system/pylon.service
[Unit]
Description=pylon
After=network-online.target

[Service]
EnvironmentFile=/etc/pylon/env
# App source (app.ts, functions/, app/, node_modules) lives here.
# TypeScript functions + SSR need bun on the host:
#   curl -fsSL https://bun.sh/install | BUN_INSTALL=/usr/local bash
WorkingDirectory=/opt/my-app
ExecStart=/usr/local/bin/pylon start app.ts
Restart=on-failure
RestartSec=5s
User=pylon
Group=pylon
NoNewPrivileges=true
ProtectSystem=strict
ReadWritePaths=/var/lib/pylon

[Install]
WantedBy=multi-user.target
```

Reverse proxy (Caddy or nginx) forwards `:443` → `:4321` plus WebSocket upgrades for `/ws` → `:4322`, `/events` (SSE) → `:4323`, and shard WS → `:4324`. `pylon deploy --target systemd` emits a matching nginx or Caddy reverse-proxy config alongside the unit file (generated by `crates/runtime/src/tls.rs`).

```sh theme={null}
systemctl enable --now pylon
```

Backups: cron `pylon backup /var/backups/pylon/$(date +%F)` nightly. Test restore quarterly per the test at `crates/runtime/tests/backup_restore.rs`.

### Caddy example

```
your-app.com {
    reverse_proxy localhost:4321

    handle /ws {
        reverse_proxy localhost:4322
    }
    handle /events {
        reverse_proxy localhost:4323
    }
    handle /shard/* {
        reverse_proxy localhost:4324
    }
}
```

## 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.

Compiled with `--features postgres-live` and `DATABASE_URL=postgres://...`.

## Shape 3: AWS via SST (TypeScript IaC)

For TypeScript-first infrastructure, [SST v3](https://sst.dev) provisions the same AWS shape from a single `sst.config.ts`. Same components (Aurora, Fargate, ALB, EFS, CloudFront), one CLI for deploys + secrets + per-PR preview environments.

```bash theme={null}
sst deploy --stage production
```

See **[Deploy with SST](/operations/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. Scale-to-zero: idle apps cost \$0. Cost rises with actual request volume. See [Workers costs](/operations/workers-costs).

Realtime shards (tick-based sims) are not yet supported on Workers — they need persistent state that Workers-only can't hold efficiently. Use shape 1 or 2 for game shards.

## Shape 5: Pylon Cloud

```bash theme={null}
pylon login
pylon deploy --target cloud
```

Done. See [Cloud](/cloud) for full details.

## Shape 6: local dev

```sh theme={null}
pylon dev
```

Starts on port 4321 with `PYLON_DEV_MODE=true` defaults. Studio at `/studio`, hot-reload, permissive CORS. **Not for production.**

## Health checks

* `GET /health` returns 200 with `{"status":"ok","uptime_seconds":N}`
* `GET /metrics` returns Prometheus text when `Accept: text/plain`
* `GET /readyz` checks DB connectivity

Hook these into your load balancer — unhealthy instances should drain.

## Shutdown and rolling deploys

`SIGTERM` terminates the process. The runtime does **not** currently install a
signal handler, so SIGTERM does not run an in-process drain — the drain path
exists in the runtime but isn't yet wired to a signal. Two things make an
abrupt stop safe in practice:

* **SQLite (WAL mode) is crash-consistent.** An abrupt stop won't corrupt the
  database; the next start recovers the WAL. There's no "flush on exit" to lose.
* **Drain at the load balancer, not in the process.** For rolling deploys with
  zero dropped requests: stop routing new traffic to the old instance, wait out
  your load balancer's connection-drain / deregistration delay so in-flight
  requests finish, then stop the process.

Rolling deploy sequence: start the new instance, let the load balancer
health-check promote it, stop routing traffic to the old one, wait for the
drain window, then `SIGTERM` it.

(`PYLON_DRAIN_SECS`, default 10s, bounds the drain loop for the paths that *do*
invoke the runtime's shutdown — e.g. programmatic shutdown; it is not a SIGTERM
grace period.)

## Native clients (iOS, macOS, Android)

Native clients hit the same HTTP + WebSocket endpoints as browsers, with two notes:

* **CORS doesn't apply** — native HTTP clients skip CORS entirely. Don't worry about `PYLON_CORS_ORIGIN` for them.
* **TLS is mandatory on iOS** — App Transport Security rejects `ws://` and `http://` to non-localhost hosts. Always use `wss://` and `https://` in production. Self-signed certs work in dev with an ATS exception in `Info.plist`.
* **Background sessions** — for large file uploads/downloads that should continue when the app is backgrounded, configure `URLSessionConfiguration.background(...)` in your transport. See [Swift SDK](/clients/swift).

## Scale-out

Single-process by design. For higher throughput:

* **Reads**: cache + rely on the 4-connection read pool (already in)
* **Writes**: move to Postgres (`postgres-live` feature)
* **WS fanout**: workers + Durable Objects; shape 3 amortizes edge
* **Shards**: run one process per game region; load-balance by match id

Horizontal HA isn't a first-class shape yet. If you need multi-master SQLite, you don't want SQLite — switch to Postgres.

For multi-region, on Pylon Cloud, set the workspace region during signup. Self-hosted multi-region is a manual exercise (one Pylon per region, 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:

```bash theme={null}
docker run -d \
  --name pylon \
  -p 4321:4321 -p 4322:4322 -p 4323:4323 -p 4324:4324 \
  -v $(pwd):/app \
  -v /var/lib/pylon:/data \
  --env-file /etc/pylon/env \
  ghcr.io/pylonsync/pylon:latest
```

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 generate a Dockerfile that copies your app onto the runtime image and serves it with `pylon start`.
