# Scaling and high availability (/platform/self-hosting/scaling)

Configure shared storage, Redis, and database capacity before adding replicas. Measure your execution workload before choosing memory limits or autoscaling targets.

## Prerequisites before scaling past one replica \[#prerequisites-before-scaling-past-one-replica]

Note:

Verify Redis connectivity before raising `replicaCount`. If you disable or remove bundled Redis, supply a working `REDIS_URL` for app and realtime. Process-local fallbacks do not deliver cross-pod events; see [Redis](/platform/self-hosting/redis).

You also need shared object storage — local-disk storage is per-pod, so a file uploaded through one replica is invisible to the others. See [Object Storage](/platform/self-hosting/object-storage).

## What scales how \[#what-scales-how]

| Component | Scaling | Notes |
| - | - | - |
| **app** | Horizontal | Stateless once object storage is configured. **Requires Redis** past one replica |
| **realtime** | Horizontal | **Requires Redis** past one replica (Socket.IO adapter) |
| **postgresql** | Vertical + read replicas | The eventual bottleneck |
| **redis** | Vertical / HA pair | Coordination only; small |
| **cronjobs** | Fixed | One call per tick regardless of replica count |

## Scaling the app \[#scaling-the-app]

```yaml
app:
  replicaCount: 3
  resources:
    limits:
      memory: 8Gi
      cpu: 2000m
    requests:
      memory: 4Gi
      cpu: 1000m
```

The `resources` block above is the chart's default — it is shown so the numbers are visible, not because it changes anything. Only `replicaCount` needs setting unless you are raising the ceiling.

The app orchestrates workflows, and local JavaScript runs in Node child workers inside its container. Those workers and in-memory file parsing share the pod's memory limit. OOMKills can terminate in-flight runs.

Measure steady and peak memory with representative files, execution concurrency, and sandbox providers. The chart's 4 GiB request and 8 GiB limit are defaults, not measured capacity guarantees. Raise the limit or reduce concurrent work when your workload exceeds it; also measure CPU rather than assuming memory is always the bottleneck.

A PodDisruptionBudget is created automatically once `replicaCount > 1`, or once `autoscaling.enabled` with `minReplicas > 1` (`maxUnavailable: 25%`). The app and realtime each consider their own `replicaCount`, but the autoscaling term is shared: `minReplicas > 1` creates both PDBs, including realtime's when `autoscaling.realtime.enabled` is false and no realtime HPA exists. Tighten it with `podDisruptionBudget.minAvailable` if you need to.

### Autoscaling \[#autoscaling]

```yaml
autoscaling:
  enabled: true
  minReplicas: 2
  maxReplicas: 10
  targetCPUUtilizationPercentage: 70
  targetMemoryUtilizationPercentage: 80
```

Requires `metrics-server`. When enabled, the chart omits `spec.replicas` so the HPA owns replica count.

Warning:

Scale-down terminates pods that may be running workflows. Set a conservative `minReplicas`, and consider a `behavior` block with a long `stabilizationWindowSeconds` on scale-down so long executions are not repeatedly interrupted.

Realtime gets the same HPA unless you disable it — and again, only scale it past one replica with Redis configured:

```yaml
autoscaling:
  realtime:
    enabled: false
```

## Database \[#database]

Database connection and storage capacity must support all app and realtime replicas.

### Connections \[#connections]

Each process can open more than one database pool. Budget the sum of active pools across replicas and workers against PostgreSQL's `max_connections`, with headroom for migrations and administration.

| Pool | Default primary ceiling | Replica ceiling | Use |
| - | - | - | - |
| `web` | 10 | 4 | Main app pool |
| `realtime` | 5 | 3 | Realtime process |
| `trigger` | 5 | 2 | Trigger.dev worker process |
| `exec` | 10 | 4 | Execution subpool profile; current `dbFor` client opens a primary pool |
| `cleanup` | 5 | 2 | Cleanup subpool profile; current `dbFor` client opens a primary pool |
| `search` | 5 | 0 | File-search subpool |
| `billing` | 4 | 0 | Usage-ledger and threshold-billing subpool |
| `auth` | 3 | 0 | Session and API-key lookup subpool |

App subpools are created lazily, so a web process using all five can open up to 37 primary connections in addition to its configured read-replica pool. Count the actual pools and URLs used by each process. CronJob pods make HTTP calls to the app; they do not open their own PostgreSQL pools.

A pooler such as **PgBouncer** can separate app replica count from database connection count. Before using transaction pooling, verify the application's session-lock requirements and give the migrator a [direct, non-pooled connection](/platform/self-hosting/upgrades#migration-environment). Point the chart at the chosen endpoint with `externalDatabase.enabled: true`, `externalDatabase.host`, and `externalDatabase.port`, alongside `postgresql.enabled: false`. Both halves are required — with `postgresql.enabled: false` and `externalDatabase.enabled` still false the chart renders an empty `DATABASE_URL`. Setting `app.env.DATABASE_URL` does nothing — the chart computes `DATABASE_URL` itself and inlines it on the container, where it overrides anything from the Secret. Set the pooler's server-connection budget for your database and verify the resulting workload before increasing replicas.

### Read replicas \[#read-replicas]

Heavy read paths — log listing, audit logs, dashboard aggregations — can be offloaded:

```bash
DATABASE_REPLICA_URL=postgresql://user:pass@replica-host:5432/simstudio
```

Reads fall back to the primary when unset. Per-role overrides exist if different components should use different replicas:

| Variable | Applies to |
| - | - |
| `DATABASE_REPLICA_URL` | Default for all roles |
| `DATABASE_REPLICA_URL_WEB` | The web app |
| `DATABASE_REPLICA_URL_REALTIME` | The realtime service |
| `DATABASE_REPLICA_URL_TRIGGER` | Trigger.dev workers |

Read replicas can lag. Recently written logs may briefly be absent from reads routed to a replica. Monitor replication lag and use the primary where immediate visibility is required.

### Sizing \[#sizing]

These are starting estimates for a managed database, not workload benchmarks:

| Example deployment | Instance | Storage |
| - | - | - |
| Small (1–5 users) | 2 vCPU / 8 GiB | 50 GiB |
| Standard (5–50 users) | 4 vCPU / 16 GiB | 100 GiB+ |
| Large (50+ users) | 8+ vCPU / 32 GiB+ | 250 GiB+, auto-grow |

Embeddings, logs, and table data grow with usage and retention, not just member count. Measure query latency and data growth; configure storage growth and alerts for the selected database service.

## Execution concurrency \[#execution-concurrency]

`SCHEDULE_EXECUTION_CONCURRENCY_LIMIT` (default `30`) bounds scheduled executions across the default database-backed queue. The other three `*_EXECUTION_CONCURRENCY_LIMIT` variables apply only to Trigger.dev and are inert on a default self-host — see [Background Jobs](/platform/self-hosting/background-jobs#concurrency).

Adding replicas does not multiply this shared cap. If the queue is full, consider raising it only with memory and database headroom. If memory is exhausted, reduce concurrency or add capacity before raising the limit.

## Rate limits and quotas \[#rate-limits-and-quotas]

Billing-disabled self-hosted deployments run **without plan limits by default**. Plan rate limits, execution timeouts, and table/storage caps can be opted back in individually. Fixed security, request-size, and admission controls still apply; the variable list and suggested values are in [Environment Variables](/platform/self-hosting/environment-variables#limits).

An execution timeout is worth setting even on an otherwise unlimited deployment — it is what stops a runaway workflow from holding a sandbox indefinitely.

## Reference topology \[#reference-topology]

An illustrative topology with three minimum app replicas, two static realtime replicas, and a shared database pooler follows. It is not a throughput guarantee. With the shared HPA enabled, realtime also uses `minReplicas: 3` unless you set `autoscaling.realtime.enabled: false`.

```yaml
app:
  replicaCount: 3
  env:
    REDIS_URL: "rediss://:<password>@redis.internal:6380"

realtime:
  replicaCount: 2
  env:
    REDIS_URL: "rediss://:<password>@redis.internal:6380"

postgresql:
  enabled: false

externalDatabase:
  enabled: true
  host: "pgbouncer.internal"
  port: 6432
  database: simstudio
  sslMode: require
  # Replace with your real password. It must match ^[a-zA-Z0-9._-]+$ — the
  # chart rejects anything else, including the angle brackets a placeholder
  # would normally use.
  #
  # Required unless you use externalDatabase.existingSecret or External
  # Secrets, either of which skips both checks. Under External Secrets, map
  # externalSecrets.remoteRefs.externalDatabase.password — an omitted mapping
  # renders no Secret and the pod cannot start.
  password: "replace-with-your-password"

autoscaling:
  enabled: true
  minReplicas: 3
  maxReplicas: 10

podDisruptionBudget:
  minAvailable: 2
```

Plus: managed Postgres with PITR, managed Redis in an HA tier, object storage with versioning, and images pinned to an explicit tag.

## Related docs \[#related-docs]

- [Observability](/platform/self-hosting/observability)
- [Upgrades](/platform/self-hosting/upgrades)
