> ## 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 with SST

> Ship Pylon to AWS as a stateless Fargate container with managed Aurora Postgres, S3 file storage, ALB, and a CDN — defined in one TypeScript file.

[SST](https://sst.dev) is a TypeScript framework for defining AWS (and other cloud) infrastructure. It's the cleanest way to deploy Pylon to AWS without writing Terraform — one `sst.config.ts` provisions Aurora Postgres, an S3 bucket for uploads, an ECS Fargate cluster, a load balancer with WebSocket + SSE + shard ports forwarded, and a CloudFront CDN.

The default shape is **stateless**: Postgres holds app data + sessions, S3 holds files. The container can scale horizontally without losing state. A working reference config ships in [`deploy/sst/sst.config.ts`](https://github.com/pylonsync/pylon/blob/main/deploy/sst/sst.config.ts).

## What you get

* **Aurora Serverless v2 Postgres** for app data and sessions (auto-scaling 0.5–2 ACU, \~\$15/mo minimum)
* **S3 bucket** for file uploads (linked to the service for IAM)
* **ECS Fargate** running the Pylon container (0.25 vCPU / 512 MB \~ \$9/mo, horizontally scalable)
* **Application Load Balancer** with WebSocket + SSE + shard port forwarding and sticky sessions
* **AWS Secrets Manager** for the admin token + OAuth credentials
* **CloudFront CDN** in front of the ALB
* **Route 53 + ACM** for custom domains and TLS

Total minimum bill: \~\$25/mo for a production deployment.

## Prerequisites

```bash theme={null}
# 1. SST CLI (v3 / Ion)
curl -fsSL https://ion.sst.dev/install | bash

# 2. AWS CLI configured
aws configure  # or AWS_PROFILE / SSO

# 3. Pylon CLI for local dev
curl -fsSL https://www.pylonsync.com/install.sh | bash

# 4. Docker (for local Postgres in dev)
# Already installed if you've used Docker Desktop / OrbStack
```

You'll need an AWS account with permissions to create VPCs, ECS services, RDS, ALB, ACM, Route 53, S3, and Secrets Manager. SST's default IAM role assumes broad permissions; lock down per your org's standards.

## Project layout

```
my-pylon-app/
├── app.ts                     # Pylon schema + manifest entry
├── pylon.manifest.json        # generated
├── functions/                 # server functions
├── Dockerfile                 # Pylon's published Dockerfile or your own
├── docker-compose.dev.yml     # local Postgres for dev
├── sst.config.ts              # SST infrastructure definition
└── package.json
```

<Note>
  The container image must bundle the **Pylon binary + Bun** — the binary spawns
  Bun to run your TypeScript functions and SSR. Pylon's published image
  (`ghcr.io/pylonsync/pylon`, which `pylon deploy --target docker` builds on)
  already includes both. If you write your own Dockerfile, install Bun (`curl
      -fsSL https://bun.sh/install | bash`) or your app's functions/SSR will fail at
  runtime.
</Note>

## The default config — Aurora + S3

```typescript theme={null}
/// <reference path="./.sst/platform/config.d.ts" />

export default $config({
  app(input) {
    return {
      name: "my-pylon-app",
      removal: input?.stage === "production" ? "retain" : "remove",
      home: "aws",
      providers: { aws: { region: "us-east-1" } },
    };
  },
  async run() {
    // ── Secrets ──────────────────────────────────────────────────
    const adminToken = new sst.Secret("PylonAdminToken");
    const oauthGoogle = new sst.Secret("OAuthGoogleClientSecret");
    const oauthGithub = new sst.Secret("OAuthGithubClientSecret");

    // ── Database ─────────────────────────────────────────────────
    // Aurora Serverless v2 — auto-scales 0.5 → 2 ACU. Holds app data
    // AND sessions; the container is stateless.
    const db = new sst.aws.Postgres("PylonDb", {
      scaling: { min: "0.5 ACU", max: "2 ACU" },
    });

    // ── File storage ─────────────────────────────────────────────
    // Pylon's S3 backend signs requests with static credentials read
    // from env — it does NOT auto-discover the ECS task role — so
    // provision a bucket-scoped IAM user and hand the service its key.
    const uploads = new sst.aws.Bucket("PylonUploads");
    const s3User = new aws.iam.User("PylonS3User");
    new aws.iam.UserPolicy("PylonS3UserPolicy", {
      user: s3User.name,
      policy: uploads.arn.apply((arn) =>
        JSON.stringify({
          Version: "2012-10-17",
          Statement: [
            {
              Effect: "Allow",
              Action: ["s3:PutObject", "s3:GetObject", "s3:DeleteObject"],
              Resource: `${arn}/*`,
            },
          ],
        }),
      ),
    });
    const s3Key = new aws.iam.AccessKey("PylonS3Key", { user: s3User.name });

    // ── Cluster + service ────────────────────────────────────────
    const cluster = new sst.aws.Cluster("PylonCluster", {
      vpc: { id: db.nodes.vpc.id },
    });

    new sst.aws.Service("PylonService", {
      cluster,
      cpu: "0.25 vCPU",
      memory: "512 MB",
      image: { dockerfile: "./Dockerfile" },
      health: { command: ["CMD-SHELL", "curl -fsS http://localhost:4321/health || exit 1"], interval: "30 seconds" },
      // Stateless container — safe to scale horizontally.
      scaling: { min: 1, max: 4, cpuUtilization: 70 },
      link: [uploads],
      environment: {
        // Core
        DATABASE_URL: db.url,
        PYLON_PORT: "4321",
        PYLON_DEV_MODE: "false",
        // File storage — pylon's built-in S3 backend (SigV4 presigned URLs).
        PYLON_FILES_PROVIDER: "s3",
        PYLON_S3_BUCKET: uploads.name,
        PYLON_S3_REGION: "us-east-1",
        PYLON_S3_ACCESS_KEY: s3Key.id,
        PYLON_S3_SECRET_KEY: s3Key.secret,
        // Auth (secrets)
        PYLON_ADMIN_TOKEN: adminToken.value,
        // Client-facing
        PYLON_CORS_ORIGIN: "https://your-app.com",
        PYLON_CSRF_ORIGINS: "https://your-app.com",
        // OAuth (optional)
        PYLON_OAUTH_GOOGLE_CLIENT_ID: "your-google-client-id",
        PYLON_OAUTH_GOOGLE_CLIENT_SECRET: oauthGoogle.value,
        PYLON_OAUTH_GITHUB_CLIENT_ID: "your-github-client-id",
        PYLON_OAUTH_GITHUB_CLIENT_SECRET: oauthGithub.value,
      },
      loadBalancer: {
        // ALB rules forward all four Pylon ports:
        //   4321 → HTTP API
        //   4322 → WebSocket sync
        //   4323 → SSE fallback (/events)
        //   4324 → realtime shards
        rules: [
          { listen: "443/https", forward: "4321/http" },
          { listen: "4322/tcp",  forward: "4322/tcp" },
          { listen: "4323/tcp",  forward: "4323/tcp" },
          { listen: "4324/tcp",  forward: "4324/tcp" },
        ],
        // ACM cert provisioned automatically when domain is set:
        domain: { name: "api.your-app.com", dns: sst.aws.dns() },
        idleTimeout: "3600 seconds",
        // Required when scaling.max > 1: keep WebSockets pinned to one
        // replica so reconnects don't lose presence state.
        stickySessions: true,
      },
    });
  },
});
```

<Note>
  **Credentials are read from env only.** Pylon's S3 backend signs requests
  with `PYLON_S3_ACCESS_KEY` / `PYLON_S3_SECRET_KEY` (plus an optional
  `PYLON_S3_SESSION_TOKEN` for temporary/STS credentials). It does **not**
  auto-discover the ECS task role from the container credentials endpoint or
  EC2 IMDS, so `link: [uploads]` alone isn't enough — provision a
  bucket-scoped IAM user's static key as shown above, or inject the task
  role's temporary credentials (access key + secret + session token) into env
  via your entrypoint.

  The server validates the S3 env at boot and **fails fast** if
  `PYLON_FILES_PROVIDER=s3` but a required var is missing — it never silently
  falls back to local disk (which on a stateless Fargate container would lose
  uploads on every redeploy).
</Note>

## Local development

Mirror the production stack locally so SQL queries, indexes, and policies behave identically. The cheapest way is Postgres in Docker + local-disk file storage — no MinIO needed for dev unless your code exercises S3-specific behavior.

`docker-compose.dev.yml`:

```yaml theme={null}
services:
  db:
    image: postgres:16-alpine
    ports: ["5432:5432"]
    environment:
      POSTGRES_USER: pylon
      POSTGRES_PASSWORD: pylon
      POSTGRES_DB: pylon
    volumes:
      - pylon-db:/var/lib/postgresql/data

volumes:
  pylon-db:
```

Boot it once:

```bash theme={null}
docker compose -f docker-compose.dev.yml up -d
```

Run Pylon against it:

```bash theme={null}
DATABASE_URL=postgres://pylon:pylon@localhost:5432/pylon pylon dev
```

A `.env` file at the project root keeps this out of your shell history:

```env theme={null}
DATABASE_URL=postgres://pylon:pylon@localhost:5432/pylon
PYLON_FILES_DIR=./.pylon/uploads
```

`pylon dev` reads `.env` automatically; restart the dev server when you change it.

To also test against S3 locally, point `PYLON_FILES_PROVIDER=s3` at [MinIO](https://min.io) running in the same docker-compose:

```yaml theme={null}
services:
  # ...db service above...
  minio:
    image: minio/minio:latest
    command: server /data --console-address ":9001"
    ports: ["9000:9000", "9001:9001"]
    environment:
      MINIO_ROOT_USER: minioadmin
      MINIO_ROOT_PASSWORD: minioadmin
```

```env theme={null}
PYLON_FILES_PROVIDER=s3
PYLON_S3_BUCKET=pylon-uploads
PYLON_S3_ENDPOINT=http://localhost:9000
PYLON_S3_ACCESS_KEY=minioadmin
PYLON_S3_SECRET_KEY=minioadmin
```

The `file_storage` plugin treats MinIO as a drop-in S3 — same wire protocol.

## Set secrets

Before the first deploy, set the secret values via the SST CLI:

```bash theme={null}
# Generate a random admin token (256 bits)
openssl rand -hex 32 | xargs sst secret set PylonAdminToken

# OAuth secrets from Google Cloud Console / GitHub OAuth Apps
sst secret set OAuthGoogleClientSecret
sst secret set OAuthGithubClientSecret
```

Secrets are stored in AWS Parameter Store, encrypted with a KMS key SST creates per-app.

## Deploy

```bash theme={null}
# First time — provisions everything (~10 minutes for Aurora cold start)
sst deploy --stage production

# Subsequent deploys (only the container changes) — `<2 min`
sst deploy --stage production
```

Once the deploy finishes, SST prints the ALB URL. Hit it:

```bash theme={null}
curl https://api.your-app.com/health
# → {"status":"ok","uptime_seconds":42}
```

## Custom domain

The `domain` block under `loadBalancer` provisions an ACM certificate and points Route 53 at the ALB. If your DNS lives elsewhere, swap the `dns` provider:

```typescript theme={null}
domain: {
  name: "api.your-app.com",
  dns: sst.cloudflare.dns(),  // or sst.vercel.dns()
}
```

## Multiple environments

```bash theme={null}
sst deploy --stage staging       # → staging.your-app.com
sst deploy --stage production    # → api.your-app.com
sst deploy --stage pr-42         # → pr-42.preview.your-app.com (per-PR previews)
```

Each stage gets its own Aurora cluster, S3 bucket, and ALB. Use the `removal` config to make non-prod environments tear down cleanly:

```typescript theme={null}
app(input) {
  return {
    removal: input?.stage === "production" ? "retain" : "remove",
    // ...
  };
}
```

## Alternative: single-replica with local state (EFS + SQLite)

If you genuinely want to run Pylon as a single replica with SQLite + local files (e.g. an internal tool, a hobby app, or a hard requirement to avoid Postgres + S3), you can mount EFS instead of using Aurora and S3:

```typescript theme={null}
const efs = new sst.aws.Efs("PylonEfs", { vpc: { id: cluster.nodes.vpc.id } });

new sst.aws.Service("PylonService", {
  cluster,
  cpu: "0.25 vCPU",
  memory: "512 MB",
  image: { dockerfile: "./Dockerfile" },
  scaling: { min: 1, max: 1 },   // single replica only
  volumes: [{ efs, path: "/var/lib/pylon" }],
  environment: {
    PYLON_DB_PATH: "/var/lib/pylon/pylon.db",
    PYLON_SESSION_DB: "/var/lib/pylon/sessions.db",
    PYLON_FILES_DIR: "/var/lib/pylon/uploads",
    // no DATABASE_URL → uses SQLite at PYLON_DB_PATH
  },
});
```

Trade-offs:

* ✅ \~\$10/mo cheaper (no Aurora minimum)
* ✅ One backing service to think about
* ❌ Cannot scale horizontally (SQLite is single-writer; mounting EFS into multiple containers corrupts the DB)
* ❌ EFS latency (\~5ms) is meaningfully slower than Aurora (\~1ms in-VPC)
* ❌ Backups are your responsibility (Aurora has them built-in)

Use the default Aurora + S3 shape unless you have a specific reason not to.

## CDN in front of the ALB

For static-asset caching and global edge presence:

```typescript theme={null}
const cdn = new sst.aws.Cdn("PylonCdn", {
  origins: [{ domainName: service.url }],
  domain: { name: "your-app.com", dns: sst.aws.dns() },
});
```

CloudFront caches GET responses with appropriate `Cache-Control` headers. WebSocket and SSE traffic should bypass the CDN — point your sync engine's `wsUrl` directly at the ALB:

```typescript theme={null}
createSyncEngine("https://your-app.com", {
  wsUrl: "wss://api.your-app.com:4322",
});
```

## Horizontal scale

The default config already scales horizontally (`scaling: { min: 1, max: 4 }`). Bump it for read-heavy apps:

```typescript theme={null}
scaling: {
  min: 2,
  max: 20,
  cpuUtilization: 70,
}
```

Requirements for a correct multi-replica deploy:

1. **Postgres backend** ✅ — `DATABASE_URL` set, no SQLite. The default config satisfies this.
2. **Externalized file storage** ⚠️ — the default config points at S3, which isn't wired yet (see the warning under *The default config*). Use `PYLON_FILES_PROVIDER=stack0` for externalized files, or keep a single replica on the EFS + SQLite shape.
3. **Sticky WebSocket sessions** ✅ — `stickySessions: true` on the load balancer. The default config satisfies this.
4. **Cross-replica live fanout** ❌ — WebSocket broadcasts are in-process by default, so a mutation handled by replica A never reaches clients on replica B until their next `reconcile()`. The default config does **not** address this; set the cluster bus before scaling past one replica.

For cross-replica fanout, run ElastiCache Redis and point every replica at it with `PYLON_CLUSTER_BUS`. Pylon refuses to boot if the bus is set but unreachable (loud failure beats silent split-brain). See [Horizontal scaling](/operations/scaling).

```typescript theme={null}
const redis = new sst.aws.Redis("PylonBus", { vpc: { id: db.nodes.vpc.id } });

new sst.aws.Service("PylonService", {
  // ...
  link: [uploads, redis],
  environment: {
    // ...
    PYLON_CLUSTER_BUS: redis.url,      // redis://… — cross-machine change/presence/CRDT fanout
    PYLON_CLUSTER_NAMESPACE: "my-pylon-app",
  },
});
```

For shard-based multiplayer, prefer dedicated single-replica services per region — shards don't horizontally scale within a region.

## Observability

```typescript theme={null}
new sst.aws.Service("PylonService", {
  // ...
  logging: {
    retention: "30 days",
  },
});
```

Logs ship to CloudWatch automatically. For richer observability:

* **Metrics**: Pylon exposes `/metrics` in Prometheus format. Scrape with [AWS Managed Prometheus](https://aws.amazon.com/prometheus/) or [Grafana Cloud](https://grafana.com/products/cloud/).
* **Traces**: Add OpenTelemetry exporter env vars and SST will inject the right IAM permissions for X-Ray.

## Compared to Pylon Cloud

If you're not committed to AWS, [Pylon Cloud](/cloud) gives you the same managed Postgres + S3 + TLS + WebSocket-aware load balancing with one CLI command (`pylon deploy --target cloud`) and per-use pricing. SST is the right choice when:

* You need to live in AWS (compliance, existing infra, RI commitments)
* You want to compose Pylon with other AWS services (Bedrock, SageMaker, IoT Core, Kinesis)
* You want full IaC control over networking, IAM, secrets

Both are valid. Pylon's wire protocol is identical regardless of how you host it.

## Troubleshooting

**WebSocket connections fail with 504** — ALB idle timeout defaults to 60s. The default config bumps it to 1 hour via `idleTimeout: "3600 seconds"`.

**Cold start takes 10+ minutes the first time** — Aurora Serverless v2's first cold start provisions storage. Subsequent deploys are `<2 min`.

**Uploads don't land in S3** — expected: the runtime's storage selector doesn't wire the S3 backend yet (see the warning under *The default config*), so `PYLON_FILES_PROVIDER=s3` writes to local container disk. Use `PYLON_FILES_PROVIDER=stack0` for externalized storage, or the single-replica EFS + SQLite shape. (The `link: [uploads]` IAM grant is still correct for when S3 lands.)

**Local dev errors `connection refused`** — Postgres isn't running. `docker compose -f docker-compose.dev.yml ps` should show the `db` service `Up`. If not: `docker compose up -d db`.

**Local dev sessions disappear on restart** — sessions are stored in Postgres now (the same `DATABASE_URL`), so they persist as long as the Postgres volume does. If you `docker compose down -v` (note `-v`), the volume is destroyed and sessions clear.

**Secrets aren't visible in the container** — make sure you ran `sst secret set` in the same stage you're deploying to. Secrets are per-stage.

## Cost optimization

| Component                          | Default cost     | Optimization                                                     |
| ---------------------------------- | ---------------- | ---------------------------------------------------------------- |
| Aurora Serverless v2 (0.5 ACU min) | \~\$15/mo        | Drop `min` to `0.5 ACU` and let it scale up only on load         |
| Fargate (0.25 vCPU / 512 MB)       | \~\$9/mo         | Use Fargate Spot for non-prod                                    |
| ALB                                | \~\$16/mo        | Single ALB for all stages via shared listeners                   |
| S3 (bucket + traffic)              | \~\$0–5/mo       | Lifecycle policies for old uploads; CloudFront in front to cache |
| NAT Gateway                        | \~\$32/mo        | Skip the NAT (single AZ) for non-prod; use VPC endpoints in prod |
| CloudWatch logs                    | \~\$0.50/GB      | Set `retention: "7 days"` for non-prod                           |
| Secrets Manager                    | \$0.40/secret/mo | Few secrets; minor cost                                          |

A bare-bones non-prod stack runs \~$30/mo; production with NAT + multi-AZ runs ~$80–150/mo depending on traffic. For low-traffic apps, [Pylon Cloud](/cloud) is often cheaper than the AWS minimums.

## Reference config

The full working config is at [`deploy/sst/sst.config.ts`](https://github.com/pylonsync/pylon/blob/main/deploy/sst/sst.config.ts). Clone it as a starting point:

```bash theme={null}
cp deploy/sst/sst.config.ts your-app/sst.config.ts
cd your-app
sst secret set PylonAdminToken
sst deploy --stage production
```
