Skip to content

Operate

Scaling and high availability

Replicas, connection pooling, autoscaling, and where the limits actually are

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

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.

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

Scaling the app

Code
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

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

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

Code
autoscaling:
  realtime:
    enabled: false

Database

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

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.

PoolDefault primary ceilingReplica ceilingUse
web104Main app pool
realtime53Realtime process
trigger52Trigger.dev worker process
exec104Execution subpool profile; current dbFor client opens a primary pool
cleanup52Cleanup subpool profile; current dbFor client opens a primary pool
search50File-search subpool
billing40Usage-ledger and threshold-billing subpool
auth30Session 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. 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

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

Code
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

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

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

Example deploymentInstanceStorage
Small (1–5 users)2 vCPU / 8 GiB50 GiB
Standard (5–50 users)4 vCPU / 16 GiB100 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

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.

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

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.

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

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.

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

Was this helpful?
Suggest an edit