Skip to main content
SST 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.

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

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

The default config — Aurora + S3

/// <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 ─────────────────────────────────────────────
    // `link` grants the service IAM permissions on this bucket
    // automatically — no manual policy attachment needed.
    const uploads = new sst.aws.Bucket("PylonUploads");

    // ── 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 — read by the file_storage plugin in your manifest.
        PYLON_FILES_PROVIDER: "s3",
        PYLON_S3_BUCKET: uploads.name,
        PYLON_S3_REGION: "us-east-1",
        // 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,
      },
    });
  },
});
Add file_storage to your pylon.manifest.json so Pylon knows to use S3:
{
  "plugins": [
    {
      "name": "file_storage",
      "config": {
        "backend": "s3",
        "bucket": "${env.PYLON_S3_BUCKET}",
        "region": "${env.PYLON_S3_REGION}"
      }
    }
  ]
}

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:
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:
docker compose -f docker-compose.dev.yml up -d
Run Pylon against it:
DATABASE_URL=postgres://pylon:pylon@localhost:5432/pylon pylon dev
A .env file at the project root keeps this out of your shell history:
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 running in the same docker-compose:
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
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:
# 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

# 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:
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:
domain: {
  name: "api.your-app.com",
  dns: sst.cloudflare.dns(),  // or sst.vercel.dns()
}

Multiple environments

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:
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:
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:
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:
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:
scaling: {
  min: 2,
  max: 20,
  cpuUtilization: 70,
}
Requirements that the default config already satisfies:
  1. Postgres backend ✅ (DATABASE_URL set, no SQLite)
  2. External file storage ✅ (S3 via file_storage plugin)
  3. Sticky WebSocket sessions ✅ (stickySessions: true on the load balancer)
For multi-replica deployments that need a shared cache (rate limiting counters, computed-value caches), add the cache_client plugin pointed at ElastiCache Redis:
const redis = new sst.aws.Redis("PylonCache", { vpc: { id: db.nodes.vpc.id } });

new sst.aws.Service("PylonService", {
  // ...
  link: [uploads, redis],
  environment: {
    // ...
    REDIS_URL: redis.url,
  },
});
For shard-based multiplayer, prefer dedicated single-replica services per region — shards don’t horizontally scale within a region.

Observability

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 or Grafana 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 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. File uploads fail with 403 — confirm the service has the bucket linked (link: [uploads]) and that your manifest’s file_storage plugin reads ${env.PYLON_S3_BUCKET}. SST emits the bucket name into the env automatically; without link, the IAM permissions to write to it aren’t attached. 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

ComponentDefault costOptimization
Aurora Serverless v2 (0.5 ACU min)~$15/moDrop min to 0.5 ACU and let it scale up only on load
Fargate (0.25 vCPU / 512 MB)~$9/moUse Fargate Spot for non-prod
ALB~$16/moSingle ALB for all stages via shared listeners
S3 (bucket + traffic)~$0–5/moLifecycle policies for old uploads; CloudFront in front to cache
NAT Gateway~$32/moSkip the NAT (single AZ) for non-prod; use VPC endpoints in prod
CloudWatch logs~$0.50/GBSet retention: "7 days" for non-prod
Secrets Manager$0.40/secret/moFew secrets; minor cost
A bare-bones non-prod stack runs ~30/mo;productionwithNAT+multiAZruns 30/mo; production with NAT + multi-AZ runs ~80–150/mo depending on traffic. For low-traffic apps, Pylon Cloud is often cheaper than the AWS minimums.

Reference config

The full working config is at deploy/sst/sst.config.ts. Clone it as a starting point:
cp deploy/sst/sst.config.ts your-app/sst.config.ts
cd your-app
sst secret set PylonAdminToken
sst deploy --stage production