Helm Values Reference
This page is the reference for the values the TruePPM Helm chart
(packages/helm/values.yaml) exposes: what each knob does and the value it ships
with. For how many of each resource to run at a given team size, see
Deployment Sizing; for the application environment
variables passed under env, see Configuration.
Unknown keys are rejected
Section titled “Unknown keys are rejected”The chart ships a values.schema.json whose root is closed, so helm upgrade
fails on a values key no template reads instead of accepting it:
Error: values don't meet the specifications of the schema(s) in the following chart(s):trueppm:- at '': additional properties 'extraEnv' not allowedThis matters more than it sounds. Helm’s default is to accept anything, so before the schema a misspelled or invented key — copied from a blog post, another chart, or a typo — applied cleanly, changed nothing on the pod, and left you comparing a UI that said the setting was configured against a cluster where it was not. If a key you believe in is rejected, it is not a key this chart reads; find the real one below.
Two blocks stay deliberately open because the chart is not the authority on their
contents: global (Helm’s cross-chart channel) and anything passed straight through
to Kubernetes with toYaml — resources.*, podSecurityContext,
containerSecurityContext, ingress.annotations, alerts.labels, and
backup.extraVolumes / extraVolumeMounts.
Image and replicas
Section titled “Image and replicas”| Key | Default | What it does |
|---|---|---|
replicaCount | 1 | API tier replica count. Raise to 2+ for production (the prod overlay sets 2). Request throughput scales with this because uvicorn runs one worker per pod by default. |
image.repository | registry.gitlab.com/trueppm/trueppm/api | API container image. |
image.webRepository | registry.gitlab.com/trueppm/trueppm/web | Web (nginx SPA) image; shares tag/pullPolicy with the API so a release deploys a matching pair. |
image.tag | "" | Empty pins the chart to its own appVersion for reproducible rollbacks, resolving to v<appVersion> (e.g. v0.4.0) — released images are published under v-prefixed tags, so the v is part of the tag, not decoration. Override per-deploy with a concrete tag, which is used verbatim. |
image.pullPolicy | IfNotPresent | Standard Kubernetes pull policy. |
Service and web tier
Section titled “Service and web tier”| Key | Default | What it does |
|---|---|---|
service.type / service.port | ClusterIP / 8000 | API Service. Stays ClusterIP; the Ingress is the sole external object. |
web.enabled | true | Serve the compiled React SPA from an in-chart nginx tier. Disable if you front the SPA from your own CDN and want only the API + workers. |
web.replicaCount | 1 | Web-tier replicas; falls back to replicaCount when unset. |
web.containerPort | 8080 | Port the unprivileged nginx image listens on (satisfies runAsNonRoot). |
web.service.type / web.service.port | ClusterIP / 80 | Web Service. |
web.maxBodySize | 110M | nginx client_max_body_size for the web tier. Inert in the default topology — the Ingress sends /api and /ws straight to the API Service, so uploads never traverse this nginx. It binds when you route everything through the web tier instead. See Upload size limits. |
Django admin exposure
Section titled “Django admin exposure”| Key | Default | What it does |
|---|---|---|
web.adminAccess.enabled | true | Render the /admin/ proxy at all. Set false to return 404 instead — removes the path from the public listener entirely. |
web.adminAccess.allowCIDRs | [] | Source CIDRs permitted to reach /admin/. Empty means deny everything. Matched against nginx’s $remote_addr, which behind an Ingress is the controller’s pod IP, not the operator’s — so this is only meaningful when the web tier sees real client addresses. |
web.adminAccess.rateLimit.enabled | true | Apply an nginx limit_req zone to the admin login surface. |
web.adminAccess.rateLimit.rate | 5r/m | Requests per source IP, matching the Docker Compose deployment. |
web.adminAccess.rateLimit.burst | 2 | Burst allowance above the sustained rate. |
SPA security headers
Section titled “SPA security headers”The chart ships the same set the Docker Compose deployment sets, and every value
is tunable: operators front this tier with their own ingress, WAF, or CDN, and a
CSP that breaks the app is worse than no CSP. Set any value to "" to omit that
one header.
| Key | Default | What it does |
|---|---|---|
web.securityHeaders.enabled | true | Render the header block at all. Only set false when a trusted upstream (an ingress configuration-snippet, a WAF, a CDN edge) already sets the same headers — nginx cannot merge or deduplicate a header the upstream also emits. |
web.securityHeaders.frameOptions | DENY | X-Frame-Options. The SPA is never legitimately framed, so DENY rather than SAMEORIGIN. |
web.securityHeaders.contentTypeOptions | nosniff | X-Content-Type-Options. Stops a browser re-typing a response as a script. |
web.securityHeaders.contentSecurityPolicy | default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data: blob:; connect-src 'self' ws: wss:; font-src 'self'; frame-ancestors 'none' | Content-Security-Policy. connect-src includes ws:/wss: for the real-time collaboration socket. Widen it if you host fonts or images off-origin or add an analytics endpoint. Serving the SPA and API from different origins is not supported — see Split-origin deploys. |
web.securityHeaders.strictTransportSecurity | "" (off) | Strict-Transport-Security. Off by default, unlike the Compose TLS template: in the chart’s default topology TLS terminates at the Ingress and this nginx speaks plain HTTP, and most ingress controllers emit HSTS themselves. Turn it on — "max-age=63072000; includeSubDomains" — only when the web tier is your TLS edge, and note that includeSubDomains binds every sibling subdomain of the apex you serve from. |
All four render with nginx’s always flag, so they are present on error
responses too, not just 200s.
Celery worker tuning
Section titled “Celery worker tuning”| Key | Default | What it does |
|---|---|---|
celeryWorker.concurrency | 2 | Prefork pool size, pinned. Never left unset: Celery’s cpu_count() default reads the node’s core count rather than the cgroup CPU limit, so an unpinned worker forks a node-sized pool into a 2Gi pod and OOM-kills the whole background tier. Raise toward the pod’s CPU limit per the sizing profiles. |
celeryWorker.maxTasksPerChild | 100 | Recycle each prefork child after this many tasks so long-running jobs (workspace export, MS Project import) cannot accumulate RSS for the pod’s lifetime. 0 disables recycling. |
celeryWorker.extraArgs | [] | Extra celery worker flags, appended verbatim and in order — e.g. ["--queues=exports", "--prefetch-multiplier=1"]. |
Ingress
Section titled “Ingress”Off by default — the ingress class, hostnames, and certificate source are cluster-specific, so a default-on ingress would render a broken object.
| Key | Default | What it does |
|---|---|---|
ingress.enabled | false | Render a chart-managed Ingress + edge TLS. |
ingress.className | "" | IngressClass to bind (nginx, traefik, …). Empty uses the cluster default. |
ingress.annotations | nginx.ingress.kubernetes.io/proxy-body-size: "110m" | Controller / cert-manager annotations. The shipped default raises the upload ceiling — see Upload size limits. Helm deep-merges this map, so your own keys are added alongside it. |
ingress.hosts | one example host | Virtual hosts; each path routes to web or api. List /api and /ws before / so they win longest-prefix matching. |
ingress.tls | [] | TLS Secrets per host. Empty renders HTTP-only — dev/demo only, never production. |
Upload size limits
Section titled “Upload size limits”Two ceilings sit in front of every import, and they are enforced in different places. Get the order wrong and a valid file is rejected by the proxy before the application ever sees it.
| Layer | Where | Default |
|---|---|---|
| Ingress controller | ingress.annotations → nginx.ingress.kubernetes.io/proxy-body-size | 110m |
| Web-tier nginx | web.maxBodySize | 110M |
| Application — attachments | MAX_ATTACHMENT_SIZE_BYTES / DATA_UPLOAD_MAX_MEMORY_SIZE (both 100 MB) | see Configuration |
| Application — imports | MSPROJECT_MAX_UPLOAD_MB (50), JIRA_IMPORT_MAX_UPLOAD_MB (25), CSV_IMPORT_MAX_UPLOAD_MB (10), SEED_MAX_UPLOAD_MB (5) | see Configuration |
The rule: keep every transport limit at or above the largest application cap.
The application cap is the one that should reject an oversized file, because it
returns a validation error naming the limit and the format. A transport limit
returns a bare 413 with no explanation and nothing in the logs pointing at the
import.
The largest cap is the attachment one at 100 MB, not the MS Project import at
50 — which is what both transport limits were sized against before 0.4, so every
attachment between 50 and 100 MB was rejected at the edge. Both now ship at 110,
the extra 10 covering multipart framing: at exactly 100 a legal 100 MB upload
still 413s. If you raise any application cap, raise both transport limits past
it — otherwise the higher app cap is unreachable.
Which transport limit actually binds depends on your topology. In the default
routing the Ingress sends /api straight to the API Service, so uploads never
traverse the web tier and only the ingress annotation applies;
web.maxBodySize binds when you route everything through the web tier instead.
Keep them in step rather than reasoning about which one is live.
On other ingress controllers the annotation is a no-op. Traefik uses a
buffering middleware with maxRequestBodyBytes; HAProxy uses
haproxy.org/client-body-buffer-size. Set the equivalent for your controller.
Bundled datastores
Section titled “Bundled datastores”| Key | Default | What it does |
|---|---|---|
postgresql.enabled | true | Deploy the bundled PostgreSQL. |
postgresql.auth.username / .database | trueppm / trueppm | Bundled DB credentials. |
postgresql.auth.password | "" | Empty ⇒ chart generates a strong random password and persists it in the connection Secret (never churned on re-render). Set explicitly only to control the credential. |
valkey.enabled | true | Deploy the bundled Valkey. Load-bearing for Channels, the Celery broker, and the cache at once. |
valkey.auth.enabled | true | Valkey auth on by default. |
valkey.auth.password | "" | Same generate-and-persist pattern as PostgreSQL. |
postgresql.persistence.size / .storageClass | 8Gi / "" | Bundled database volume. Undocumented before 0.4, so the default was undiscoverable. |
valkey.persistence.size / .storageClass | 2Gi / "" | Bundled Valkey volume. |
valkey.maxmemory | 768mb | Memory ceiling, set below the 1Gi container limit. Valkey accounts for its dataset, not for copy-on-write during AOF rewrite or allocator overhead, so a maxmemory equal to the limit still OOMKills. Neither this nor the policy below was set before 0.4 — Valkey grew until the container was OOMKilled, and because /readyz gates on cache reachability that is a full API outage, not a cache miss. |
valkey.maxmemoryPolicy | noeviction | Required, not a preference. This instance is the Celery broker and the Channels layer, not only a cache: under allkeys-lru a memory-pressure event silently discards queued tasks and in-flight WebSocket group state. With noeviction the write fails loudly instead, which is recoverable. |
postgresql.podDisruptionBudget.enabled | true | PDB on the bundled database, maxUnavailable: 0. This does not make a single-replica database highly available — it makes a node drain block and surface as something the operator can see, rather than a silent eviction that takes the whole release down behind it. Override with kubectl drain --disable-eviction. |
postgresql.priorityClassName | "" | A PriorityClass you have created, so the database is not the first thing evicted under pressure. |
postgresql.terminationGracePeriodSeconds | 120 | Time to finish a checkpoint and shut down cleanly. The 30s Kubernetes default can cut a large checkpoint short and force crash recovery on next start. |
global.trueppm.connectionSecretName | "" | Override only if you renamed the chart-owned connection Secret. |
Network and pod security
Section titled “Network and pod security”| Key | Default | What it does |
|---|---|---|
networkPolicy.enabled | true | Restrict datastore ingress to the API/worker pods and default-deny datastore egress. Requires a policy-enforcing CNI (Calico, Cilium, Antrea, …) — silently unenforced without one. |
podSecurityContext | runAsNonRoot: true, runAsUser: 1000 | Pod-level restricted defaults. |
containerSecurityContext | no-priv-escalation, read-only rootfs, drop ALL caps, RuntimeDefault seccomp | Container-level restricted defaults. |
Resources
Section titled “Resources”Per-tier requests/limits under resources.<tier> for api, worker, beat,
and web. Defaults are conservative single-team values (API/worker request
250m / 512Mi, limit 1 / 2Gi; beat and web are light). Each includes an
ephemeral-storage request/limit for /tmp scratch (MS Project parse, export,
large request buffering). Tune per the sizing profiles.
Health probes
Section titled “Health probes”For what each probe checks in plain language, and what to do when one fails, see Startup, Readiness, and Liveness Probes. This section is the values reference; that page is the troubleshooting guide.
| Key | Default | What it does |
|---|---|---|
probes.api.readinessPath | /api/v1/readyz | Deep readiness: DB + cache reachable and no unapplied/in-flight migrations, so a rolling upgrade never routes traffic to a pod whose schema and code disagree. Detection of the reverse direction — a database carrying migrations the running image does not ship, i.e. an image rolled back without restoring the schema — ships in 0.4 as migration_state: ahead, gated only for a pod that booted into it so a forward rolling upgrade never pulls the old pods out of the Service. Either way, schema presence is not data compatibility: rolling back across a destructive migration still needs a restore from backup. The disk-migration scan behind this check is cached process-wide rather than rebuilt on every call, and the endpoint carries its own rate limit (env.TRUEPPM_THROTTLE_READYZ_RATE, default 2000/min) instead of a full throttle exemption — both land in 0.4. |
probes.api.livenessPath | /api/v1/health/ | Shallow liveness so a transient dependency blip can’t restart-loop the pod. |
probes.api.readiness*/liveness*Seconds | 10/10, 30/30 | Initial-delay and period tuning. |
probes.api.hostHeader | (empty → ingress host, else <release>-trueppm-api) | Host header kubelet sends on both api probes. kubelet dials by pod IP, so without this Django validates <podIP>:8000 against ALLOWED_HOSTS in get_host() — before any view, and out of reach of SECURE_REDIRECT_EXEMPT — and answers 400 DisallowedHost. The pod never turns Ready, the Service gets no endpoints, and the Ingress serves 503, with nothing in the failure naming ALLOWED_HOSTS. Empty resolves to the first ingress host when ingress.enabled: true, and otherwise to the api Service’s own DNS name <release>-trueppm-api — the name the helm test probe already curls, so a no-Ingress install needs no value here either. Whatever it resolves to must be in ALLOWED_HOSTS. See Host names you must include. |
probes.worker.enabled / probes.beat.enabled | true | Master switch for that component’s probes. |
probes.worker.heartbeatFile | /tmp/trueppm-celery-worker-heartbeat | File a Celery signal handler touches on worker_ready/heartbeat_sent and removes on worker_shutting_down (ships 0.4, #3346). Both probes.worker.startup and probes.worker.readiness below stat this same path. |
probes.worker.startup.* | enabled, initialDelaySeconds: 0, every 5s, failureThreshold: 30 | Ships in 0.4. Checks the heartbeat file exists — true once the worker has fired worker_ready at least once, i.e. its broker connection is established. Suspends liveness until it passes, so a slow first boot cannot restart-loop. |
probes.worker.liveness.* | initial delay 60, ping every 60s, failureThreshold: 5 | Still celery inspect ping against the pod’s own worker node. This is the probe that kills the container, so it is the forgiving one — see below. |
probes.worker.readiness.* | initial delay 15, every 15s, failureThreshold: 3, staleSeconds: 30 | No longer celery inspect ping (0.4, #3236, #3346) — it stats the heartbeat file above and fails once it is older than staleSeconds. The file is refreshed by Celery’s own heartbeat_sent signal on a fixed ~2s timer, independent of task load, so — unlike the pre-0.4 ping-based check — this probe cannot be starved by a busy worker. |
probes.beat.liveness.* | initial delay 30, ping every 60s, failureThreshold: 5 | Beat’s ping targets broker reachability (the fleet, not its own node); the generous threshold avoids restarts on a brief worker blip. Beat renders a liveness probe only, so there is no probes.beat.readiness. |
probes.worker.* / probes.beat.* flat keys | (empty) | Shared override applied across that component’s probes — see Tuning celery probes apart. |
probes.web.readiness*/liveness*Seconds | 5/10, 10/30 | Ships in 0.4. Initial-delay and period tuning for the web (nginx) tier’s GET / probes — previously hardcoded in the chart. No startup key: the container serves a pre-built static bundle, so there is no boot-time dependency for a startup probe to cover. |
Tuning celery probes apart
Section titled “Tuning celery probes apart”The Celery worker’s three probes have different mechanisms and opposite consequences:
- readiness and startup failures are free. A Celery worker sits behind no
Service — both the API and web Services select on
app.kubernetes.io/component— so they pace rolling updates and gate nothing else. The pod drops out and comes back. Since 0.4 neither runsinspect ping: both stat the heartbeat file, a plain filesystem check with no fork of Django, Celery, or the broker. - a liveness failure kills the container, and the worker’s
lifecycle.worker.terminationGracePeriodSecondsis300. Celery shuts down warm (it stops prefetching and finishes what it holds), so a kill can cost up to five minutes of unavailability for that pod. Liveness still runscelery inspect ping— a missed check only costs a bounded restart, unlike a false readiness failure, which had no failure budget to absorb it.
Liveness is slow to conclude anything: its steady-state detection budget is
failureThreshold x periodSeconds = 5 x 60 = 300s, derived to match the grace it
spends, and its initial delay is 60 because a cold worker also has to fork a
Django import to answer its first inspect ping. Readiness is the opposite:
initial delay 15, period 15, so a rollout is not left idling on a worker that
is already up, and a stuck worker is caught in dozens of seconds rather than up to
a minute — safe to run this often because it no longer forks anything into the
container it measures.
probes.worker.liveness.periodSeconds (and the flat probes.worker.periodSeconds
override) still has a 60s floor enforced by CI (helm:structure-check):
inspect ping runs inside the container it measures, so probing more often
forks more Django imports into the worker’s own CPU budget and competes with the
process that has to answer it. Readiness carries no such floor from 0.4 onward — it has
nothing left to compete with.
Set the per-probe keys — probes.worker.liveness.initialDelaySeconds,
probes.worker.readiness.periodSeconds, probes.worker.startup.failureThreshold,
and so on — to change one probe without the others. Each also takes its own
enabled, empty by default and inheriting probes.worker.enabled, so you can drop
one probe and keep the others without turning all three off.
timeoutSeconds is Celery’s budget, not kubelet’s
Section titled “timeoutSeconds is Celery’s budget, not kubelet’s”timeoutSeconds (default 10) is the value passed to celery inspect ping --timeout. kubelet’s own probe timeout is derived from it as
max(timeoutSeconds + 5, 1.5 x timeoutSeconds) — 15s at the default — because it
has to cover the ping budget plus the sh fork, the Python start, and the
Django/Celery app import that every exec probe pays.
That headroom has to scale with the ping budget. If kubelet’s timeout fires first
there is no Celery stderr to read, so the failure surfaces as a bare
Liveness probe failed: with an empty body and nothing naming the cause. Raising
timeoutSeconds because a node is slow therefore widens the import headroom too.
Override the derivation with kubeletTimeoutSeconds (flat or per-probe) if it is
wrong for your nodes.
GitOps: helm template mints a new password every sync
Section titled “GitOps: helm template mints a new password every sync”The chart generates the bundled PostgreSQL and Valkey passwords when you do not
supply them, and memoizes them against the existing connection Secret via Helm’s
lookup so repeat helm install / helm upgrade runs never churn the
credential and orphan the database PVC.
lookup returns empty under helm template. An Argo CD or Flux pipeline
that renders manifests and then applies them therefore takes the
generate-a-fresh-one branch on every sync, rotating the password against a
database that still holds the old one. The symptom is an API tier that
authenticates fine until the next reconcile and then cannot connect.
Two ways out, and the first is better:
- Run managed datastores —
postgresql.enabled: false,valkey.enabled: false, and supplyenv.DATABASE_URL/env.REDIS_URLassecretKeyRefentries. Nothing is generated, so nothing can rotate. - Set the passwords explicitly —
postgresql.auth.passwordandvalkey.auth.password, sourced from your secret manager. The generate branch never runs.
This affects render-then-apply pipelines only. helm install and helm upgrade
run against a live cluster where lookup works as intended.
Placement and scheduling
Section titled “Placement and scheduling”None of these existed before 0.4, and because values.schema.json closes the
root with additionalProperties: false they were rejected, not ignored:
--set imagePullSecrets[0].name=regcred failed schema validation, so an
operator mirroring the images into a private registry could not install the
chart at all without forking it. There was no air-gapped path.
Each is rendered verbatim into the api, celery-worker, celery-beat, and web pod specs, so the valid keys are Kubernetes’, not this chart’s.
| Key | Default | What it does |
|---|---|---|
imagePullSecrets | [] | Pull secrets for a private or mirrored registry. This is the air-gapped path: mirror the images, point image.repository and web.image.repository at your registry, and name the secret here. |
priorityClassName | "" | A PriorityClass you have created. The chart creates none — a chart that mints a cluster-scoped PriorityClass steps on the cluster’s own priority budget. |
nodeSelector | {} | Node label constraints. |
tolerations | [] | Taint tolerations, e.g. for a dedicated node pool. |
affinity | {} | Full affinity / anti-affinity. Prefer topologySpreadConstraints for the ordinary “spread my replicas” case. |
topologySpreadConstraints | [] | Spread replicas across failure domains. See below. |
topologySpreadConstraints is what makes replicaCount: 2 mean something
Section titled “topologySpreadConstraints is what makes replicaCount: 2 mean something”Without it, both API pods can be scheduled onto the same node — where a node failure takes 100% of the tier and the PodDisruptionBudget is never consulted. That was the chart’s real redundancy ceiling, below what its documentation implied.
Omit labelSelector and the chart fills it in per tier (this release’s
selector labels plus that tier’s component), so one constraint written once
means the right thing on api, worker, beat, and web independently. A single
global list applied verbatim would spread whichever tier your selector happened
to name and silently no-op on the other three. Supply your own labelSelector
only to override that.
topologySpreadConstraints: - maxSkew: 1 topologyKey: kubernetes.io/hostname whenUnsatisfiable: ScheduleAnywayScheduleAnyway, not DoNotSchedule: on a cluster with fewer nodes than
replicas, DoNotSchedule leaves the second replica Pending forever, which is
a worse outcome than an imperfect spread. Tighten it once you have the nodes.
values-prod.yaml ships exactly this alongside replicaCount: 2.
Graceful shutdown
Section titled “Graceful shutdown”terminationGracePeriodSeconds and preStop existed on none of the six
workloads before 0.4, so every pod took Kubernetes’ 30-second default with no
drain window. The API serves Channels WebSockets, so every rollout dropped live
collaboration sockets.
| Key | Default | What it does |
|---|---|---|
lifecycle.api.terminationGracePeriodSeconds | 60 | Drain window for in-flight requests and WebSockets. |
lifecycle.api.preStopSleepSeconds | 5 | Sleep before SIGTERM so endpoint deregistration propagates. This is not about finishing work — it covers the window where kubelet has begun termination but the ingress is still routing here, a race the application cannot win from inside. 0 omits the hook. |
lifecycle.worker.terminationGracePeriodSeconds | 300 | Must exceed your longest task. Celery’s warm shutdown stops prefetching and finishes what it holds; this is the ceiling on that. Too low and Kubernetes SIGKILLs mid-import, after which the task waits out the broker’s visibility timeout before anything retries it. |
lifecycle.worker.preStopSleepSeconds | 0 | No hook — the worker receives no ingress traffic. |
lifecycle.beat.terminationGracePeriodSeconds | 30 | Beat holds no request; it just writes its schedule shelve. |
lifecycle.web.terminationGracePeriodSeconds | 30 | nginx finishes in-flight responses. |
lifecycle.web.preStopSleepSeconds | 5 | Same endpoint-deregistration race as the API. |
Related: CELERY_BROKER_TRANSPORT_OPTIONS["visibility_timeout"] is now bounded
at 900s in settings/base.py. Tasks carry acks_late=True, so a killed worker
does not lose work — but Kombu’s Redis default hid the unacked message for
3600s first, so a worker killed during a rolling upgrade left that task
invisible for up to an hour with nothing to say why.
Startup probe
Section titled “Startup probe”| Key | Default | What it does |
|---|---|---|
probes.api.startupEnabled | false | Enable a startup probe on the API. |
probes.api.startupPath | /api/v1/health/ | Shallow health path. |
probes.api.startupPeriodSeconds | 5 | Poll interval. |
probes.api.startupFailureThreshold | 30 | Failures before the container is restarted — 30 x 5s = 150s of boot budget. |
Without one, liveness governs the boot: initialDelay 30 + period 30 x the
default failureThreshold: 3 gives roughly 120s before a restart loop begins,
measured after the migrate, bootstrap, and collectstatic init containers have
already consumed wall-clock. A startup probe moves that budget somewhere
explicit and suspends liveness until it passes, so a slow first boot on a loaded
node cannot restart-loop while a genuinely wedged process is still caught. Off
by default because it changes restart semantics; on is the right call on a
loaded cluster.
Scaling and availability
Section titled “Scaling and availability”| Key | Default | What it does |
|---|---|---|
podDisruptionBudget.enabled | false | PDBs for API, worker, and web (maxUnavailable: 1). Only meaningful at replicaCount >= 2; beat is excluded (pinned singleton). The web tier had no PDB before 0.4 — the one tier a browser loads was the unprotected one. |
podDisruptionBudget.web.maxUnavailable | 1 | Budget for the web tier. |
autoscaling.enabled | false | HorizontalPodAutoscaler for the API (and optionally worker). Requires metrics-server. Defaults: API 2–6 replicas at 75% CPU. From 0.4 the Deployment omits replicas entirely while an HPA owns the tier — previously it set replicas unconditionally, so every helm upgrade reset the count and the HPA scaled it back up: a scale-down flap per release. |
autoscaling.worker.enabled | false | Worker HPA. Leave off. It scales on CPU, which is the wrong signal for a queue consumer blocked on BRPOP — it will not scale out under backlog, and it can scale in while tasks are queued. Use fixed worker replicas with a pinned celeryWorker.concurrency until queue-depth scaling exists. |
logging.level | "" | Fleet-wide DJANGO_LOG_LEVEL (DEBUG/INFO/WARNING/ERROR). Empty keeps the app default. |
Application environment (env)
Section titled “Application environment (env)”The env block passes application settings into the API/worker/beat containers.
The full catalog lives in Configuration; the
knobs operators reach for first:
| Key | Default | What it does |
|---|---|---|
env.DJANGO_SETTINGS_MODULE | trueppm_api.settings.prod | Settings module. |
env.DATABASE_URL / env.REDIS_URL | unset | Required when the bundled datastores are disabled, and rejected while they are enabled (the chart-built URL always wins, so your value would be silently ignored) — the render fails either way, with a message saying which. Two supported shapes, both injected via secretKeyRef so neither reaches a Deployment: a secretKeyRef map naming a Secret you manage (preferred — the credential never passes through Helm), or a URL string (the chart stores it in its own connection Secret, but it persists in your values file / shell history / Helm release Secret on the way). env.REDIS_URL is not required when valkey.sentinel.enabled is true. See Managed datastores. |
valkey.sentinel.enabled | false | Experimental (0.4). Use a Valkey Sentinel topology instead of a single endpoint. Only honored when valkey.enabled is false. Validate a real failover in staging before depending on it. |
valkey.sentinel.nodes | "" | Comma-separated host:port Sentinel list. Required when valkey.sentinel.enabled is true. |
valkey.sentinel.masterName | "" | Name the Sentinels monitor the primary under. Required when valkey.sentinel.enabled is true. |
valkey.sentinel.password / .sentinelPassword | "" | Data-node and Sentinel-node passwords. Routed through the chart-owned connection Secret, never rendered into a Deployment. |
valkey.sentinel.tls | false | Use TLS to the Valkey data nodes. |
env.TRUEPPM_FRONTEND_BASE_URL | "" | Public origin for absolute deep-links in notification emails, and the page the OIDC callback sends the browser to once sign-in completes. Leave empty on this chart’s default single-origin Ingress. |
env.TRUEPPM_PUBLIC_API_BASE_URL | "" | Public origin of the API. Pins the OIDC redirect_uri and the inbound Git-webhook URL instead of deriving them from the request’s Host header. Set it with SSO, and whenever your edge does not preserve Host — TruePPM ignores X-Forwarded-Host by design. |
env.TRUEPPM_THROTTLE_ANON_RATE / _USER_RATE | 60/min / 1000/min | API rate limits. /health/ and /edition/ are always exempt (they do no dependency work); /readyz is not — from 0.4 it has its own dedicated, generous scope (env.TRUEPPM_THROTTLE_READYZ_RATE, default 2000/min) instead of a full exemption, because unlike the other two it does a real database and cache round-trip per call. |
env.TRUEPPM_NUM_PROXIES | "1" | Trusted reverse-proxy depth for real-client-IP extraction. A wrong value lets clients spoof X-Forwarded-For. |
env.TRUEPPM_RATE_LIMIT_ENABLED | "true" | Global API rate-limiting kill switch. Leave "true" in production. Disabling also requires TRUEPPM_RATE_LIMIT_DISABLE_ACK; for load testing only (details). |
env.TRUEPPM_PROJECT_SOFT_DELETE_RETENTION_DAYS | "30" | Trashed-project hard-delete window, in days. Do not set 0 — it is not “use the default”, it puts the purge cutoff at the present moment and deletes every trashed project, with all child data, via CASCADE. From 0.4 the app will refuse to boot on 0 rather than losing the data silently. An empty string is invalid, not “disabled”. To turn auto-purge off, leave this unset and disable the policy in Settings → System Health. See Retention. |
envFrom | [] | Bulk-inject env vars from existing Secrets/ConfigMaps (e.g. - secretRef: {name: trueppm-env}) into the API, Celery worker, and the bootstrap/migrate init containers. This is the supported way to supply SECRET_KEY, ALLOWED_HOSTS, and INTEGRATION_ENCRYPTION_KEY — the values prod refuses to boot without — without rendering them in plaintext into env. An explicit env: key of the same name always takes precedence over an envFrom entry. |
Managed (external) datastores
Section titled “Managed (external) datastores”With postgresql.enabled: false / valkey.enabled: false, the chart can no
longer build the connection strings, so you supply them. Both shapes below are
injected into every consumer — API, Celery worker, Celery beat, the migrate and
bootstrap init containers, and the backup CronJob — via secretKeyRef, so
neither renders a credential into a Deployment manifest.
Preferred — a Secret you manage. The URL never passes through Helm, so it is absent from your values file, your shell history, and the Helm release Secret. The chart points the containers straight at your Secret and does not copy the value into its own:
env: DATABASE_URL: secretKeyRef: name: trueppm-db key: url REDIS_URL: secretKeyRef: name: trueppm-cache key: urlAlternative — a URL string. The chart moves it into the chart-owned connection Secret and injects it from there, so it stays out of the Deployment; but it passed through Helm, so it persists wherever it was held:
env: DATABASE_URL: "postgres://user:pass@db.example.com:5432/trueppm?sslmode=require"An external DATABASE_URL must carry sslmode=require — settings.prod refuses
to boot on a plaintext external database. The chart cannot check this for the
secretKeyRef form, since it never sees the value; there the guard is the app’s
alone, at boot.
Observability
Section titled “Observability”| Key | Default | What it does |
|---|---|---|
observability.otlp.endpoint | "" | OTLP collector endpoint. Empty ⇒ telemetry off. |
observability.otlp.protocol | grpc | grpc (4317) or http/protobuf (4318). |
observability.otlp.serviceName | trueppm-api | Resource service.name reported on every exported span/metric. |
observability.otlp.enabled | true | Master export switch (only exports when an endpoint is also set). |
observability.otlp.tracesEnabled / metricsEnabled | true / true | Per-signal export toggles, consulted only when enabled is true and an endpoint is set. Turn one off to export only the other. |
observability.otlp.tracesSampler / Arg | "" | Trace sampling for busy instances, e.g. parentbased_traceidratio + 0.1. Empty keeps the SDK default (parentbased_always_on). |
observability.otlp.headers | "" | Comma-separated key=value OTLP headers (e.g. an auth token), rendered inline. Prefer headersSecret below for anything sensitive. |
observability.otlp.actorAttributes | true | Stamp trueppm.user.id (the acting account’s opaque UUID) and trueppm.user.role (the symbolic project role the request was authorized under) on each request span. Nothing else about the person is exported — no email, username, display name, or client IP. Set false where a per-user identifier must not leave the instance even to your own collector; project/program/task ids are unaffected. |
observability.otlp.headersSecret | unset | Prefer this over inline headers so auth tokens never render into a plaintext manifest. |
observability.otlp.exportHealth.enabled | true | Master switch for the live export-health recorder (ADR-0601). When on, each pod records per-signal export success/error/counts into Valkey DB 2 so the Telemetry card at Settings → Workspace → Observability shows a cross-process live strip. false reverts the card to a config-only posture; export itself is unaffected either way. Requires the Valkey DB 2 instance to run maxmemory-policy noeviction — the same requirement the rate-limit counters already impose. |
observability.otlp.exportHealth.stalenessSeconds | "" (app default 600) | How long a pod counts as live after its last export; beyond this a silent pod reads “never” instead of stalled. |
observability.otlp.exportHealth.healthyWithinSeconds | "" (app default 150) | A success newer than this reads healthy; older (but still live) reads stalled (metrics) / idle (traces). Must stay below stalenessSeconds, or the stalled/idle states become unobservable. Set all three exportHealth tuning keys together, or none. |
observability.otlp.exportHealth.windowSeconds | "" (app default 60) | Rolling window the exported-item counts cover; the Telemetry card labels the strip from it (e.g. “last 60s”). |
dashboards.enabled | false | Ship the starter Grafana dashboard as a labeled ConfigMap (needs a Grafana sidecar watching for the label below). |
dashboards.label / labelValue | grafana_dashboard / "1" | Label key/value your Grafana sidecar watches for auto-import. Defaults match the upstream kube-prometheus-stack sidecar convention. |
dashboards.annotations | {} | Extra annotations on the dashboard ConfigMap. |
alerts.enabled | false | Ship starter PrometheusRule alerts (requires the Prometheus Operator CRDs) covering beat staleness, outbox depth/age, dead-letter, outbound email, backups, and volume capacity. Thresholds tunable under alerts.thresholds below. |
alerts.labels | {} | Extra labels stamped on the PrometheusRule, e.g. release: kube-prometheus-stack so the operator’s ruleSelector picks it up. |
alerts.thresholds.beatStaleFor | 2m | How long the Beat heartbeat must read stale (via the /api/v1/health/beat/ Blackbox probe) before the alert fires. |
alerts.thresholds.outboxDepth | 500 | Outbox row-count threshold that starts the outboxDepthFor clock. |
alerts.thresholds.outboxDepthFor | 10m | How long outboxDepth must stay breached before the alert fires. |
alerts.thresholds.outboxOldestAgeSeconds | 900 | Age (seconds) of the oldest pending outbox row that starts the outboxOldestAgeFor clock. |
alerts.thresholds.outboxOldestAgeFor | 10m | How long outboxOldestAgeSeconds must stay breached before the alert fires. |
alerts.thresholds.deadLetter | 0 | Dead-letter gauge value that starts the deadLetterFor clock — any dead-lettered message is worth alerting on. |
alerts.thresholds.deadLetterFor | 5m | How long the dead-letter gauge must stay above deadLetter before the alert fires. |
alerts.thresholds.backup.jobFailedFor | 5m | How long a failed backup Job must persist before TruePPMBackupJobFailed fires. Rendered only when backup.enabled. |
alerts.thresholds.backup.staleAfterSeconds | 172800 | Age (seconds) of the last successful backup that fires TruePPMBackupStale. 48h = 2x the default daily schedule, so one missed run is tolerated and two are not. Raise this if you lengthen backup.schedule — a weekly schedule under a 48h window alerts every week by construction. |
alerts.thresholds.backup.staleFor | 30m | How long staleAfterSeconds must stay breached before the alert fires. |
alerts.thresholds.backup.neverSucceededFor | 26h | How long the “no successful backup has ever been recorded” condition must hold before TruePPMBackupNeverSucceeded fires. Must exceed one full schedule period plus slack, or a fresh install alerts before its first scheduled run. |
alerts.thresholds.volumeAvailablePercent | 15 | Free-space percentage below which TruePPMVolumeFillingUp starts its clock, for every claim in the namespace — database, Valkey, backups, media. |
alerts.thresholds.volumeAvailableFor | 15m | How long a volume must stay below volumeAvailablePercent before the alert fires. |
otelCollector.enabled | false | Documentation-only reminder — the chart bundles no Collector; deploy one as a sibling release. |
helm test
Section titled “helm test”| Key | Default | What it does |
|---|---|---|
tests.image.repository / tag | curlimages/curl / 8.11.1 | Image for the helm test connection-check Job. Only pulled when you run helm test <release>, never during a normal install/upgrade. Runs under the same restricted securityContext as the app containers. |
tests.probeReadyz | true | Whether the connection check also probes /api/v1/readyz in addition to /api/v1/health/. Set false only when testing this chart against an app image that predates readyz (e.g. a CI drill pinned to the last released image while the chart is ahead of it) — otherwise the probe 404s on an endpoint that image doesn’t have yet. |
Attachment storage (persistence.media)
Section titled “Attachment storage (persistence.media)”Required whenever attachments live on local disk — that is, whenever you set
TRUEPPM_ALLOW_LOCAL_ATTACHMENT_STORAGE=true instead of pointing
TRUEPPM_DEFAULT_FILE_STORAGE at object storage. Ships in 0.4.
The pods run with readOnlyRootFilesystem: true, so without this claim there is
no writable path for an upload to land in, and the API refuses to start
rather than accept uploads it cannot keep. The chart mounts the claim on every
container that imports Django settings — the api container and its
migrate/bootstrap/collectstatic init containers, the Celery worker, and Celery
beat — and sets TRUEPPM_MEDIA_ROOT from mountPath so the app and the volume
cannot disagree.
“Media” is broader than attachments. Everything Django’s default storage writes lands here: task attachments, the workspace logo, seed-import payloads, and the project, program, and workspace export bundles. The export bundles are written by the Celery worker and served back through the API, which is why the worker mounts the same claim rather than its own scratch volume. Size the claim with those in mind — the drain and purge beat jobs prune them, but a bundle is much larger than a typical attachment.
| Key | Default | What it does |
|---|---|---|
persistence.media.enabled | false | Create and mount the attachment/media claim. |
persistence.media.existingClaim | "" | Use a claim you already manage. The access-mode guard below is skipped for it — the chart cannot read an existing claim’s mode at render time. |
persistence.media.storageClass | "" | Storage class for the chart-created claim; empty uses the cluster default. |
persistence.media.accessMode | ReadWriteMany | See below. ReadWriteOnce is rejected above one API replica. |
persistence.media.size | 20Gi | Claim size. |
persistence.media.mountPath | /var/lib/trueppm/media | Mount path, and the value TRUEPPM_MEDIA_ROOT is set to. |
Why the access mode is load-bearing
Section titled “Why the access mode is load-bearing”ReadWriteOnce binds a claim to a single node, and two independent things need
the same files:
- Across API replicas. An upload accepted by pod A is a
404from pod B. The chart refuses to renderReadWriteOncetogether withreplicaCount > 1orautoscaling.enabledrather than ship that 404. - Across tiers. api, celery-worker, and celery-beat are three separate
Deployments that all mount the claim, and the API writes a seed-import payload
through storage that a Celery task opens back. With
ReadWriteOncethose pods must land on the same node or the later ones stayPendingwith a multi-attach error. That holds on single-node k3s or kind and is not guaranteed anywhere else.
So ReadWriteOnce is a single-node evaluation setting. Beyond one node, use a
ReadWriteMany storage class (CephFS, NFS, Azure Files, EFS, Longhorn-RWX) — or
skip local storage entirely and set TRUEPPM_DEFAULT_FILE_STORAGE +
TRUEPPM_S3_BUCKET_NAME, which is the better answer for any multi-node cluster.
Include the claim in backups by setting backup.mediaDir to the same
mountPath and mounting it read-only through backup.extraVolumes — see
Scheduled backups below and
Backup & restore.
Scheduled backups
Section titled “Scheduled backups”Off by default — a backup CronJob needs a durable destination, so you turn it on
deliberately. This is logical backup only (pg_dump); see Backup &
Restore for the full runbook.
| Key | Default | What it does |
|---|---|---|
backup.enabled | false | Enable the backup CronJob. |
backup.schedule | "0 2 * * *" | Cron schedule (cluster timezone). |
backup.image | postgres:16-alpine | Client-capable image carrying pg_dump/psql (the lean app image has no client binaries). |
backup.outputDir | /backups | In-container artifact path (the mounted volume when persistence is on). |
backup.mediaDir | "" | Include a local media/attachment PVC in the artifact. Set it to persistence.media.mountPath and mount the same claim through extraVolumes below. Leave empty when attachments live in object storage. |
backup.keepDaily / keepWeekly | 7 / 4 | keepDaily is enforced in-job. keepWeekly is read by no template — nothing promotes dailies to weeklies. It exists as the documented place to record the weekly retention your object store’s lifecycle policy enforces, next to the schedule it belongs to; changing it changes nothing on the cluster. |
backup.persistence.* | disabled, 10Gi RWO | Chart-managed PVC destination. |
backup.s3.* | disabled | S3-compatible off-cluster destination; the secret must come from a Kubernetes Secret via existingSecret. |
backup.extraVolumes / extraVolumeMounts | [] | Mount the media claim (<release>-trueppm-media, or your persistence.media.existingClaim) read-only when mediaDir is set. values.yaml carries a copy-pasteable pair. An RWO media claim is unreadable here while the api pod holds it unless the Job lands on the same node — one more reason persistence.media.accessMode defaults to ReadWriteMany. |
backup.resources | 100m/256Mi → 1/512Mi | Backup job container resources. |
Admin bootstrap
Section titled “Admin bootstrap”| Key | Default | What it does |
|---|---|---|
admin.passwordFile | /run/trueppm/admin_password | Where the one-time bootstrap password is written. Retrieve with kubectl exec <api-pod> -- cat /run/trueppm/admin_password. |
admin.email | "" | Bootstrap admin email. Set it — left empty the bootstrap uses admin@example.com, a reserved domain that cannot receive password-reset mail. |
Public read-only demo mode
Section titled “Public read-only demo mode”Turns a release into a throwaway public demo. A post-install/post-upgrade hook Job
seeds the bundled sample project and mints two anonymous, read-only share links — one
schedule, one board — which become the only publicly reachable way in. Demo mode also
swaps the web tier’s nginx config for an allowlist: /admin/, /ws/ and every
/api/ route other than the share projections and the liveness probe return 404, and
every response carries X-Robots-Tag: noindex alongside a Disallow: / robots.txt.
| Value | Default | Effect |
|---|---|---|
demo.enabled | false | Master switch. Everything else in the block is inert while false. |
demo.baseUrl | "" | Public origin, no trailing slash. Required when enabled — it cannot be inferred from inside the cluster. |
demo.shareToken.schedule | "" | Pinned token for the schedule link. Required when enabled. |
demo.shareToken.board | "" | Pinned token for the board link. Required when enabled, and must differ from the schedule token. |
demo.backoffLimit | 2 | Seed Job retries. Exhaustion fails the release deliberately — a demo without data is broken. |
demo.resources | see values.yaml | Requests/limits for the short-lived seed Job. |
helm install trueppm ./packages/helm \ -f packages/helm/values-demo.yaml \ --set demo.baseUrl=https://demo.example.com \ --set demo.shareToken.schedule="$(openssl rand -base64 32 | tr -d '=+/')" \ --set demo.shareToken.board="$(openssl rand -base64 32 | tr -d '=+/')"Two things that are easy to get wrong:
- Both tokens are required and must differ. Share-link hashes are globally unique, so one token cannot back both links. The chart refuses to render otherwise.
- Pinning is mandatory, not cosmetic. Because the seed is destructive and share
links cascade with their project, an unpinned link would change its public URL on
every
helm upgrade.
A bootstrap superuser still exists on a demo release — the API creates one on every
deploy — but it has no public login surface, because the allowlist closes /admin/.
Reach it with kubectl port-forward svc/<release>-trueppm-api 8000:8000.
Ready-made overlay: packages/helm/values-demo.yaml, which also sizes Celery down and
disables autoscaling, the PodDisruptionBudget, and backups.
Related
Section titled “Related”- Deployment Sizing — how many of each to run, with the team-of-25 and team-of-250 profiles.
- Configuration — the full application environment-variable catalog.
- Deployment — the stateful services and Docker Compose topology.
- Backup & Restore — the backup CronJob runbook.