What scales how
| Component | Scaling | Notes |
|---|---|---|
| app | Horizontal | Stateless. 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 |
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: 1000mMemory 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.
A PodDisruptionBudget is created automatically once replicaCount > 1 (maxUnavailable: 25%). Tighten it with podDisruptionBudget.minAvailable if you need to.
Autoscaling
autoscaling:
enabled: true
minReplicas: 2
maxReplicas: 10
targetCPUUtilizationPercentage: 70
targetMemoryUtilizationPercentage: 80Requires 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: falseDatabase
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 DATABASE_URL at it. 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/simstudioReads 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 |
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
| Deployment | Instance | Storage |
|---|---|---|
| Small (1–5 users) | 2 vCPU / 8 GB | 50 GB |
| Standard (5–50 users) | 4 vCPU / 16 GB | 100 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
resources:
limits: { memory: 8Gi, cpu: 2000m }
requests: { memory: 4Gi, cpu: 1000m }
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
autoscaling:
enabled: true
minReplicas: 3
maxReplicas: 10
podDisruptionBudget:
minAvailable: 2Plus: managed Postgres with PITR, managed Redis in an HA tier, object storage with versioning, and images pinned to an explicit tag.