Skip to content

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.

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 allowed

This 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 toYamlresources.*, podSecurityContext, containerSecurityContext, ingress.annotations, alerts.labels, and backup.extraVolumes / extraVolumeMounts.

KeyDefaultWhat it does
replicaCount1API 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.repositoryregistry.gitlab.com/trueppm/trueppm/apiAPI container image.
image.webRepositoryregistry.gitlab.com/trueppm/trueppm/webWeb (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.pullPolicyIfNotPresentStandard Kubernetes pull policy.
KeyDefaultWhat it does
service.type / service.portClusterIP / 8000API Service. Stays ClusterIP; the Ingress is the sole external object.
web.enabledtrueServe 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.replicaCount1Web-tier replicas; falls back to replicaCount when unset.
web.containerPort8080Port the unprivileged nginx image listens on (satisfies runAsNonRoot).
web.service.type / web.service.portClusterIP / 80Web Service.
web.maxBodySize110Mnginx 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.
KeyDefaultWhat it does
web.adminAccess.enabledtrueRender 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.enabledtrueApply an nginx limit_req zone to the admin login surface.
web.adminAccess.rateLimit.rate5r/mRequests per source IP, matching the Docker Compose deployment.
web.adminAccess.rateLimit.burst2Burst allowance above the sustained rate.

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.

KeyDefaultWhat it does
web.securityHeaders.enabledtrueRender 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.frameOptionsDENYX-Frame-Options. The SPA is never legitimately framed, so DENY rather than SAMEORIGIN.
web.securityHeaders.contentTypeOptionsnosniffX-Content-Type-Options. Stops a browser re-typing a response as a script.
web.securityHeaders.contentSecurityPolicydefault-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.

KeyDefaultWhat it does
celeryWorker.concurrency2Prefork 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.maxTasksPerChild100Recycle 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"].

Off by default — the ingress class, hostnames, and certificate source are cluster-specific, so a default-on ingress would render a broken object.

KeyDefaultWhat it does
ingress.enabledfalseRender a chart-managed Ingress + edge TLS.
ingress.className""IngressClass to bind (nginx, traefik, …). Empty uses the cluster default.
ingress.annotationsnginx.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.hostsone example hostVirtual 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.

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.

LayerWhereDefault
Ingress controlleringress.annotationsnginx.ingress.kubernetes.io/proxy-body-size110m
Web-tier nginxweb.maxBodySize110M
Application — attachmentsMAX_ATTACHMENT_SIZE_BYTES / DATA_UPLOAD_MAX_MEMORY_SIZE (both 100 MB)see Configuration
Application — importsMSPROJECT_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.

KeyDefaultWhat it does
postgresql.enabledtrueDeploy the bundled PostgreSQL.
postgresql.auth.username / .databasetrueppm / trueppmBundled 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.enabledtrueDeploy the bundled Valkey. Load-bearing for Channels, the Celery broker, and the cache at once.
valkey.auth.enabledtrueValkey auth on by default.
valkey.auth.password""Same generate-and-persist pattern as PostgreSQL.
postgresql.persistence.size / .storageClass8Gi / ""Bundled database volume. Undocumented before 0.4, so the default was undiscoverable.
valkey.persistence.size / .storageClass2Gi / ""Bundled Valkey volume.
valkey.maxmemory768mbMemory 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.maxmemoryPolicynoevictionRequired, 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.enabledtruePDB 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.terminationGracePeriodSeconds120Time 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.
KeyDefaultWhat it does
networkPolicy.enabledtrueRestrict datastore ingress to the API/worker pods and default-deny datastore egress. Requires a policy-enforcing CNI (Calico, Cilium, Antrea, …) — silently unenforced without one.
podSecurityContextrunAsNonRoot: true, runAsUser: 1000Pod-level restricted defaults.
containerSecurityContextno-priv-escalation, read-only rootfs, drop ALL caps, RuntimeDefault seccompContainer-level restricted defaults.

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.

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.

KeyDefaultWhat it does
probes.api.readinessPath/api/v1/readyzDeep 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*Seconds10/10, 30/30Initial-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.enabledtrueMaster switch for that component’s probes.
probes.worker.heartbeatFile/tmp/trueppm-celery-worker-heartbeatFile 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: 30Ships 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: 5Still 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: 30No 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: 5Beat’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*Seconds5/10, 10/30Ships 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.

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 runs inspect 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.terminationGracePeriodSeconds is 300. 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 runs celery 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:

  1. Run managed datastorespostgresql.enabled: false, valkey.enabled: false, and supply env.DATABASE_URL / env.REDIS_URL as secretKeyRef entries. Nothing is generated, so nothing can rotate.
  2. Set the passwords explicitlypostgresql.auth.password and valkey.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.

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.

KeyDefaultWhat 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: ScheduleAnyway

ScheduleAnyway, 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.

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.

KeyDefaultWhat it does
lifecycle.api.terminationGracePeriodSeconds60Drain window for in-flight requests and WebSockets.
lifecycle.api.preStopSleepSeconds5Sleep 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.terminationGracePeriodSeconds300Must 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.preStopSleepSeconds0No hook — the worker receives no ingress traffic.
lifecycle.beat.terminationGracePeriodSeconds30Beat holds no request; it just writes its schedule shelve.
lifecycle.web.terminationGracePeriodSeconds30nginx finishes in-flight responses.
lifecycle.web.preStopSleepSeconds5Same 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.

KeyDefaultWhat it does
probes.api.startupEnabledfalseEnable a startup probe on the API.
probes.api.startupPath/api/v1/health/Shallow health path.
probes.api.startupPeriodSeconds5Poll interval.
probes.api.startupFailureThreshold30Failures 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.

KeyDefaultWhat it does
podDisruptionBudget.enabledfalsePDBs 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.maxUnavailable1Budget for the web tier.
autoscaling.enabledfalseHorizontalPodAutoscaler 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.enabledfalseWorker 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.

The env block passes application settings into the API/worker/beat containers. The full catalog lives in Configuration; the knobs operators reach for first:

KeyDefaultWhat it does
env.DJANGO_SETTINGS_MODULEtrueppm_api.settings.prodSettings module.
env.DATABASE_URL / env.REDIS_URLunsetRequired 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.enabledfalseExperimental (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.tlsfalseUse 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_RATE60/min / 1000/minAPI 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.

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

Alternative — 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=requiresettings.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.

KeyDefaultWhat it does
observability.otlp.endpoint""OTLP collector endpoint. Empty ⇒ telemetry off.
observability.otlp.protocolgrpcgrpc (4317) or http/protobuf (4318).
observability.otlp.serviceNametrueppm-apiResource service.name reported on every exported span/metric.
observability.otlp.enabledtrueMaster export switch (only exports when an endpoint is also set).
observability.otlp.tracesEnabled / metricsEnabledtrue / truePer-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.actorAttributestrueStamp 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.headersSecretunsetPrefer this over inline headers so auth tokens never render into a plaintext manifest.
observability.otlp.exportHealth.enabledtrueMaster 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.enabledfalseShip the starter Grafana dashboard as a labeled ConfigMap (needs a Grafana sidecar watching for the label below).
dashboards.label / labelValuegrafana_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.enabledfalseShip 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.beatStaleFor2mHow long the Beat heartbeat must read stale (via the /api/v1/health/beat/ Blackbox probe) before the alert fires.
alerts.thresholds.outboxDepth500Outbox row-count threshold that starts the outboxDepthFor clock.
alerts.thresholds.outboxDepthFor10mHow long outboxDepth must stay breached before the alert fires.
alerts.thresholds.outboxOldestAgeSeconds900Age (seconds) of the oldest pending outbox row that starts the outboxOldestAgeFor clock.
alerts.thresholds.outboxOldestAgeFor10mHow long outboxOldestAgeSeconds must stay breached before the alert fires.
alerts.thresholds.deadLetter0Dead-letter gauge value that starts the deadLetterFor clock — any dead-lettered message is worth alerting on.
alerts.thresholds.deadLetterFor5mHow long the dead-letter gauge must stay above deadLetter before the alert fires.
alerts.thresholds.backup.jobFailedFor5mHow long a failed backup Job must persist before TruePPMBackupJobFailed fires. Rendered only when backup.enabled.
alerts.thresholds.backup.staleAfterSeconds172800Age (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.staleFor30mHow long staleAfterSeconds must stay breached before the alert fires.
alerts.thresholds.backup.neverSucceededFor26hHow 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.volumeAvailablePercent15Free-space percentage below which TruePPMVolumeFillingUp starts its clock, for every claim in the namespace — database, Valkey, backups, media.
alerts.thresholds.volumeAvailableFor15mHow long a volume must stay below volumeAvailablePercent before the alert fires.
otelCollector.enabledfalseDocumentation-only reminder — the chart bundles no Collector; deploy one as a sibling release.
KeyDefaultWhat it does
tests.image.repository / tagcurlimages/curl / 8.11.1Image 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.probeReadyztrueWhether 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.

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.

KeyDefaultWhat it does
persistence.media.enabledfalseCreate 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.accessModeReadWriteManySee below. ReadWriteOnce is rejected above one API replica.
persistence.media.size20GiClaim size.
persistence.media.mountPath/var/lib/trueppm/mediaMount path, and the value TRUEPPM_MEDIA_ROOT is set to.

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 404 from pod B. The chart refuses to render ReadWriteOnce together with replicaCount > 1 or autoscaling.enabled rather 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 ReadWriteOnce those pods must land on the same node or the later ones stay Pending with 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.

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.

KeyDefaultWhat it does
backup.enabledfalseEnable the backup CronJob.
backup.schedule"0 2 * * *"Cron schedule (cluster timezone).
backup.imagepostgres:16-alpineClient-capable image carrying pg_dump/psql (the lean app image has no client binaries).
backup.outputDir/backupsIn-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 / keepWeekly7 / 4keepDaily 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 RWOChart-managed PVC destination.
backup.s3.*disabledS3-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.resources100m/256Mi1/512MiBackup job container resources.
KeyDefaultWhat it does
admin.passwordFile/run/trueppm/admin_passwordWhere 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.

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.

ValueDefaultEffect
demo.enabledfalseMaster 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.backoffLimit2Seed Job retries. Exhaustion fails the release deliberately — a demo without data is broken.
demo.resourcessee values.yamlRequests/limits for the short-lived seed Job.
Terminal window
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.