Deployment Sizing
This page has two halves. The tested envelope states what a pre-release build ahead of the 0.4 beta tag has been measured to hold and names the constraint that sets each ceiling. The sizing tiers that follow are derived from the shipped Helm defaults and the shape of the workload, not from measured capacity — adjust them once you have run TruePPM against your real schedules.
Tested envelope
Section titled “Tested envelope”Measured against a pre-release build ahead of the 0.4 beta tag, on 2026-07-26, against commit 89fc5137f.
These are the numbers TruePPM has been tested to hold. They are not the maximum it can hold, and they are not a promise about your hardware. Where a ceiling is set by known, already-triaged work, that issue is named — so you can judge whether your shape of project sits near an edge.
Method
Section titled “Method”Measured with the capacity harness in packages/api/perf/capacity/, which you can re-run yourself. It steps load up until the first sustained breach rather than driving a fixed profile, where a breach is p95 > 2 s or an error rate > 1%. 2 s is the “still usable” line for opening a schedule.
The stack under test is an isolated Docker Compose stack running settings.prod with DEBUG=False and the shipped image’s default command — which is a single uvicorn process. That single process is itself one of the constraints below.
Hardware: Apple M1 Max, 10 cores (8 performance / 2 efficiency), 32 GiB RAM, macOS 26.5.1; Docker Desktop allocated 10 CPUs and 7.75 GiB. Postgres 16 with shared_buffers=1GB, effective_cache_size=3GB, max_connections=200.
This is a single-node developer-class machine, which is deliberately close to what a 0.4 beta self-hoster actually runs — not a tuned multi-node production cluster. A dedicated server will do better; a small VPS will do worse.
What was measured
Section titled “What was measured”| Dimension | Tested to | Measured p95 | What sets the ceiling |
|---|---|---|---|
| Tasks per project — one page of the task list | 4,000 tasks | 0.35 s @ 500 · 0.57 s @ 1k · 1.19 s @ 2k · 1.99 s @ 4k · breaches at 8k (8.3 s) | Page-bounded, so it degrades gently. 4,000 sits on the 2 s gate — treat 2,000 as the comfortable figure |
| Whole-project load — every page, what the Schedule fetches before drawing a bar | 1,000 tasks | 0.22 s @ 100 · 0.50 s @ 250 · 0.96 s @ 500 · 1.85 s @ 1k · breaches at 2k (60 s) | #2277 — the Schedule loads the entire project into memory via fetchAllPagesParallel; there is no windowing |
| Dependency edges per project | 12,000 edges on a 4,000-task project | 0.12–0.15 s, flat — no breach found | Not the binding constraint at this scale. Untested above 12,000 |
| Projects per workspace / total tasks | 50 projects · 50,000 tasks | 0.04–0.06 s, flat — no breach found | The project-list N+1 (#1482) does not bite at this scale. Untested above 50 projects |
Two things in this table matter more than the rest.
The Schedule is the binding constraint, not the database. A project holds several thousand tasks comfortably while you page through a list, and a workspace absorbs 50,000 tasks without noticing. But when the Schedule opens a project the client pulls every page, and that is a far lower ceiling. If you work in the Gantt, plan against ~1,000 tasks per project in 0.4 — not 4,000.
Past that point it is a cliff, not a slope. Whole-project load goes from 1.85 s at 1,000 tasks to 60 s at 2,000 — a 32× jump for a 2× increase in data. Doubling from a comfortable project does not get you a slow project; it gets you one that reads as hung. That non-linearity is the single most important thing to know before committing a large plan to 0.4, and it is why #2277 is named rather than buried.
What was explicitly not measured
Section titled “What was explicitly not measured”These are untested, not unbounded. Do not read silence as a guarantee.
| Dimension | Status |
|---|---|
| Concurrent authenticated users | Not measured. Three runs of the identical single-reader step returned 90 s, 12 s and 3 s for a read the task sweep measured at 1.3 s. The spread is contention on a shared developer workstation, and a ceiling asserted from it would be invented. Needs a quiet, dedicated host. Note that the shipped image runs one uvicorn process, so throughput scales by replica count — and #2275 (ATOMIC_REQUESTS with no connection pooler) is the expected first constraint |
| Concurrent WebSocket connections per project | Not measured. See #2339 — reconnect-storm scaling is a known open question |
| Monte Carlo iterations at the task ceiling | Not measured. #2273 — Monte Carlo runs on the request thread, so it is bounded by your gateway timeout before anything else |
| Import size (rows) | Not measured here. The 10 MB / 5,000-row limit claimed for CSV/Excel import is enforced at the parser, not derived from this run — see #743 |
| Board rendering at scale | Not measured. #1538 / #2340 — the board renders every card with no virtualization |
| Gantt interaction at scale | Not measured. #1540 / #1587 — O(N) hit-test per pointermove |
| Sustained multi-day / multi-user soak | Not measured. Every figure above is a point-in-time read sweep |
Fidelity caveats
Section titled “Fidelity caveats”The seeded database is written with bulk_create, so it carries no django-simple-history rows and leaves server_version at 0. Neither is read by a schedule or task-list request, so read latency is unaffected — but on-disk size and sync-delta timings would understate an organically grown database. Generated dependencies are strictly forward-linked, which is tidier than a real plan’s topology.
Re-running this
Section titled “Re-running this”The numbers carry the version they were measured against so they can be compared next release:
The stack takes its two secrets from the environment rather than from committed defaults, so mint a throwaway pair first — they are discarded with the stack:
export CAPACITY_INTEGRATION_KEY=$(python3 -c \ 'import base64,os; print(base64.urlsafe_b64encode(os.urandom(32)).decode())')export TRUEPPM_CAPACITY_PASSWORD=$(python3 -c \ 'import secrets; print(secrets.token_urlsafe(16))')
docker compose -f packages/api/perf/capacity/docker-compose.capacity.yml up -dpython packages/api/perf/capacity/run_capacity.py --dimension all \ --out packages/api/perf/capacity/resultsdocker compose -f packages/api/perf/capacity/docker-compose.capacity.yml down -vRaw results, including the per-step host load average and noise-control readings, are committed under packages/api/perf/capacity/results/.
”Users” means concurrent active users
Section titled “”Users” means concurrent active users”Throughout this page, users means concurrent active users, not named seats. PPM tools typically run at ~30–40% concurrency — a 200-seat license rarely has 200 people clicking at once. If the numbers you are planning against are named seats rather than simultaneous sessions, divide expected load by roughly three before reading the tables below.
What the shipped defaults give you
Section titled “What the shipped defaults give you”The Helm chart (packages/helm/values.yaml) ships these defaults:
- API pod (Django + uvicorn) and Celery worker each request
250m CPU / 512Miand limit at1 CPU / 2Gi. The production overlay runs 2 API replicas. - Bundled PostgreSQL requests
250m / 1Gi, limits2 CPU / 4Gi, with an 8Gi PVC. - Bundled Valkey requests
100m / 256Mi, limits1 CPU / 1Gi, with a 2Gi PVC.
The key constraint to understand: uvicorn runs a single worker per pod (packages/api/Dockerfile, no --workers flag), so request throughput scales by replica count. Celery concurrency is pinned by the chart at celeryWorker.concurrency (default 2); raise it toward the pod’s CPU limit as you scale.
Sizing tiers
Section titled “Sizing tiers”| 50 concurrent | 100 concurrent | 200 concurrent | |
|---|---|---|---|
| API (uvicorn) replicas | 2 | 3 | 4–6 |
| Celery worker replicas | 1 (concurrency 2–4) | 2 (concurrency 4) | 3–4 (concurrency 4) |
| API total CPU / RAM | ~1 vCPU / 2Gi | ~1.5 vCPU / 3Gi | ~3 vCPU / 6Gi |
| Celery total CPU / RAM | ~1 vCPU / 2Gi | ~2 vCPU / 4Gi | ~4 vCPU / 8Gi |
| PostgreSQL | 2 vCPU / 4Gi, 20Gi disk | 2–4 vCPU / 8Gi, 50Gi | 4 vCPU / 16Gi, 100Gi |
| Valkey / Redis | 1 vCPU / 1Gi | 1 vCPU / 2Gi | 2 vCPU / 4Gi |
| Cluster total (with headroom) | ~4 vCPU / 8 GB, 1 node | ~8 vCPU / 16 GB, 2 nodes | ~16 vCPU / 32 GB, 2–3 nodes |
Two worked profiles: team of 25 vs team of 250
Section titled “Two worked profiles: team of 25 vs team of 250”The tiers above are keyed to concurrent users. Most operators plan against a team size (named seats) instead, so here are two fully worked profiles at the ends of the OSS single-program range. Both apply the ~30–40% concurrency rule: a team of 25 is ~8–10 concurrent, a team of 250 is ~75–100 concurrent. Every number is a starting point to load-test against your own workload, not a guarantee.
Profile A — team of 25 (single program, one node)
Section titled “Profile A — team of 25 (single program, one node)”A single PM and their team. Fits comfortably on one small node; the bundled subcharts are still not recommended for real data, but a single managed Postgres and managed Valkey are inexpensive at this size.
| Component | Replicas | Requests (CPU / mem) | Limits (CPU / mem) |
|---|---|---|---|
| API (uvicorn, 2 workers/pod) | 2 | 250m / 512Mi | 1 / 1Gi |
Celery worker (--concurrency 2) | 1 | 250m / 512Mi | 1 / 2Gi |
| PostgreSQL (managed) | 1 | 1 vCPU / 2Gi | 2 / 4Gi |
| Valkey / Redis (managed) | 1 | 250m / 512Mi | 1 / 1Gi |
- Cluster total (with headroom): ~4 vCPU / 8 GB, 1 node, ~20Gi Postgres disk.
- PgBouncer: not needed — connection count stays well under
max_connections=100. - API workers: the single most important non-default change is
--workers 2on the API pods; at 2 replicas that is 4 request workers, ample for ~10 concurrent.
Profile B — team of 250 (large program, dedicated pools)
Section titled “Profile B — team of 250 (large program, dedicated pools)”A large program at the top of the OSS single-program envelope. (Coordinating
multiple programs under one PMO is portfolio governance — an Enterprise
concern, enterprise#20 — not a bigger version of this profile.) Here the
scheduler CPU and the Postgres connection ceiling both bite, so Celery gets its
own pool and PgBouncer is mandatory.
| Component | Replicas | Requests (CPU / mem) | Limits (CPU / mem) |
|---|---|---|---|
| API (uvicorn, 2–3 workers/pod) | 4–6 | 500m / 1Gi | 1 / 2Gi |
Celery worker (--concurrency 4, pinned) | 3–4 | 500m / 1Gi | 2 / 4Gi |
| PostgreSQL (managed) | 1 (+ replica optional) | 4 vCPU / 16Gi | 4 / 16Gi |
| PgBouncer | 2 | 100m / 128Mi | 500m / 256Mi |
| Valkey / Redis (managed, persistent) | 1 | 1 vCPU / 2Gi | 2 / 4Gi |
- Cluster total (with headroom): ~16 vCPU / 32 GB, 2–3 nodes, ~100Gi Postgres disk.
- PgBouncer: required.
ATOMIC_REQUESTS=true+CONN_MAX_AGE=60means every API and Celery worker holds a Postgres connection; 6 API pods × 3 workers + 4 Celery pods × 4 will exceed the defaultmax_connections=100without pooling. - Celery pinning: set
celeryWorker.concurrency: 4to match the pod CPU limit. The chart pins concurrency for you — it defaults to2— so the worker never falls back to Celery’scpu_count()default, which reads the node’s core count rather than the cgroup CPU limit and gets OOM-killed on a large shared node. - Dedicated Celery node pool: keep reforecast/Monte Carlo CPU bursts off the request-serving API pods so a portfolio recompute never starves interactive traffic.
Both profiles slot into the values reference —
set replicaCount / web.replicaCount, resources.*, the managed-datastore
env.DATABASE_URL / env.REDIS_URL, and (for Profile B) celeryWorker.concurrency.
Bottlenecks, in the order they bite
Section titled “Bottlenecks, in the order they bite”- Celery / Monte Carlo CPU. The scheduler is the heavy part. A portfolio reforecast or a Monte Carlo run (P50/P80/P95) is a CPU-bound burst. At 100+ concurrent users triggering recalculations, this is the first wall you hit. Scale Celery replicas, and raise
celeryWorker.concurrencyto match the pod’s CPU limit. The chart pins this at2by default precisely so it never falls back to Celery’scpu_count()auto-detection, which reads the node’s cores rather than the cgroup limit, over-allocates, and gets OOM-killed (the dev compose file caps it at 2 for the same reason). - Single uvicorn worker per pod. WebSocket collaboration keeps connections open (the Channels capacity of 1500 is fine), but request CPU is a single worker. Add
--workers(roughly 2× vCPU) or scale replicas before reaching 100 users. This is the single most important non-default change. - Postgres connection ceiling.
CONN_MAX_AGE=60andATOMIC_REQUESTS=truemean every request runs inside a transaction and holds a connection. With many API and Celery workers, you approach PostgreSQL’s defaultmax_connections=100at the 200-user tier — add PgBouncer or raisemax_connections.
Production-vs-default caveats
Section titled “Production-vs-default caveats”These defaults are tuned for evaluation, not scale. At every tier above:
- The bundled PostgreSQL and Valkey sub-charts are dev/demo only — single replica, small PVCs, and a non-persistent Valkey that loses in-flight Celery tasks on restart. Use a managed PostgreSQL (RDS, CloudSQL, etc.) and managed Valkey (ElastiCache for Valkey, Memorystore for Valkey, etc.) instead. See Valkey High Availability for which topologies are supported.
- File attachments default to the local filesystem and are lost on pod restart. Set
TRUEPPM_DEFAULT_FILE_STORAGEto an S3-compatible or MinIO backend, together withTRUEPPM_S3_BUCKET_NAME— see object storage. - The Horizontal Pod Autoscaler is off by default, not absent. The chart ships an
autoscaling/v2HPA for the API tier (and optionally the worker tier) behindautoscaling.enabled; the defaults scale the API between 2 and 6 replicas at 75% CPU utilization. It is opt-in because an HPA overrides the staticreplicaCountand requiresmetrics-server(or a custom metrics adapter) to be installed in the cluster. Without it, scale replicas manually. See the values reference for the full key list. - Autoscale the API tier; keep the worker tier on fixed replicas. The
celeryWorker.concurrencypinning advice above and a CPU-utilization HPA are two different answers to the same load, and following both naively double-counts: the HPA adds worker pods while each pod’s concurrency is already pinned to its CPU limit, so a Monte Carlo burst can multiply total in-flight tasks well past what the database connection ceiling tolerates. Until worker autoscaling keys off queue depth rather than CPU, the safe posture isautoscaling.enabled=truewithautoscaling.worker.enabled=false— HPA for request-serving traffic, fixed replicas plus pinned concurrency for the CPU-bound queue.
Per-tier recommendation summary
Section titled “Per-tier recommendation summary”- 50 users: single node, 4 vCPU / 8 GB, a small managed PostgreSQL; run the API at 2 replicas × 2 uvicorn workers.
- 100 users: 2 nodes, 8 vCPU / 16 GB total; scale Celery separately from the API; PgBouncer optional.
- 200 users: 2–3 nodes, 16 vCPU / 32 GB; a dedicated Celery node pool; PgBouncer required; managed Valkey with persistence.
Before you commit
Section titled “Before you commit”Load-test against your own workload before committing budget or hardware. The dominant cost is workload-specific — how many schedules are active and how often reforecasts and Monte Carlo runs fire matters far more than raw user count. Until real benchmarks exist, every figure on this page is a best-guess.
See Deployment for the underlying Helm chart and Docker Compose topology.