Scaling & High Availability

What scales how

ComponentScalingNotes
appHorizontalStateless once object storage is configured. Requires Redis past one replica
realtimeHorizontalRequires Redis past one replica (Socket.IO adapter)
postgresqlVertical + read replicasThe eventual bottleneck
redisVertical / HA pairCoordination only; small
cronjobsFixedOne call per tick regardless of replica count

Prerequisites before scaling past one replica

Redis must be reachable before you raise replicaCount. Both deployments ship it by default, so this is already satisfied unless you set redis.enabled: false (Helm) or removed the redis service (Compose) without supplying REDIS_URL. Without Redis, pub/sub falls back to a process-local emitter and the Socket.IO adapter has no cross-pod transport — realtime logs one line at startup noting single-pod mode, then drops cross-pod events silently. See 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.

Scaling the app

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.

Memory is the constraint, not CPU. Workflow executions run inside the app process in isolated-vm sandboxes, and file parsing happens in memory. Production telemetry shows 4–8 GB steady with peaks to 12 GB under heavy execution load. Under-provision memory and you get OOMKills that terminate in-flight workflow runs — so raise limits.memory above the 8 Gi default if your workload shows those peaks.

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

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:

autoscaling:
  realtime:
    enabled: false

Database

Postgres is where scaling eventually stops being about replicas.

Connections

Each app replica opens a pool. Total connections grow with replica count, and Postgres has a hard max_connections. A deployment that works at 2 replicas can exhaust connections at 6.

Budget it: replicas × pool size + realtime + cronjobs + migrations + headroom must stay under max_connections.

For anything beyond a handful of replicas, put PgBouncer in transaction pooling mode in front of the database and point the chart at it 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. This is the single highest-leverage change for a large deployment — it decouples app replica count from database connection count.

Read replicas

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

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:

VariableApplies to
DATABASE_REPLICA_URLDefault for all roles
DATABASE_REPLICA_URL_WEBThe web app
DATABASE_REPLICA_URL_REALTIMEThe realtime service
DATABASE_REPLICA_URL_TRIGGERTrigger.dev workers

Replicas lag. Sim routes only latency-tolerant reads to them, but if your replica lags badly, recently written logs may briefly not appear. Monitor replication lag.

Sizing

DeploymentInstanceStorage
Small (1–5 users)2 vCPU / 8 GB50 GB
Standard (5–50 users)4 vCPU / 16 GB100 GB+
Large (50+ users)8+ vCPU / 32 GB+250 GB+, auto-grow

Knowledge base embeddings are the main growth driver — vector storage scales with document volume, not user count. Enable storage auto-increase.

Execution concurrency

SCHEDULE_EXECUTION_CONCURRENCY_LIMIT (default 30) bounds scheduled executions per app instance. The other three *_EXECUTION_CONCURRENCY_LIMIT variables apply only to Trigger.dev and are inert on a default self-host — see Background Jobs.

When executions queue but memory is fine, raise the limit; when memory is the ceiling, add replicas instead.

Rate limits and quotas

Self-hosted deployments run without plan limits by default — no rate limits, execution timeouts, or table and storage caps. Each can be opted back in individually; the variable list and suggested values are in Environment Variables.

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

A production deployment serving ~100 active users:

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.