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.
How the values are laid out
Section titled “How the values are laid out”The values are organized concern first, then tier. A knob that more than one
tier has lives in a map named for the concern and keyed by tier, where the tier is
one of api, worker (Celery worker), beat (Celery Beat), or web:
| Concern | Map |
|---|---|
| Replicas | replicaCount (fleet-wide), replicas.<tier> (per-tier override) |
| Container resources | resources.<tier> |
| Processes per pod | processes.<tier> |
| Health probes | probes.<tier> |
| Graceful shutdown | lifecycle.<tier> |
| Autoscaling | autoscaling.<tier> |
| Disruption budget | podDisruptionBudget.<tier> |
| Kubernetes Service | service (the API), service.<tier> (other tiers) |
A tier gets its own root only for things that exist for that tier and no other.
Only web: exists today, and it holds the web tier’s on/off switch and its nginx
configuration. Optional add-ons are self-contained blocks, so one root turns
each one on and holds all of its settings: backup, demo, persistence,
tests, and the bundled postgresql and valkey.
The 0.3 keys (replicaCount, service.type/port, image.*, resources.api/worker)
keep their names. That is why the API’s Service is the bare service.type
while the web tier’s is service.web.type. The rule and the full key mapping are
in ADR-1248.
If you used web.replicaCount, web.service.*, api.workers, or
celeryWorker.* from a 0.4 prerelease, see
Upgrading.
The five observability roots stay separate on purpose. observability,
otelCollector, logging, dashboards, and alerts are not one concern split
by tier. They are five switches with different consumers and different
prerequisites:
observability.otlpconfigures the application’s own OTLP exporter.otelCollectoris documentation only and renders no objects. You run the Collector as a sibling release.logging.levelsetsDJANGO_LOG_LEVELon the API, worker, and Beat containers.dashboardsrenders a ConfigMap that only a Grafana sidecar reads.alertsrenders aPrometheusRule, which needs the Prometheus Operator CRDs.
An operator enables them independently and usually only some of them. Grouping them under one key would rename keys that 0.4 prereleases already ship and give no new structure, because none of them is keyed by tier.
Image and replicas
Section titled “Image and replicas”| Key | Default | What it does |
|---|---|---|
replicaCount | 1 | Fleet-wide replica count: the API and Celery worker replicas, and the fallback for every replicas.<tier> entry. Celery Beat never reads it (a pinned singleton). Raise to 2+ for production (the prod overlay sets 2). Request throughput scales with this because uvicorn runs one worker per pod by default — raise processes.api.workers below to also scale within a pod. |
replicas.web | "" (empty) | Web-tier replicas. Empty follows the top-level replicaCount; set a number to scale the web tier independently. values-prod.yaml leaves it empty, so the web tier runs two pods and survives a node drain. values-demo.yaml pins it to 1. Two or more pods widen the rolling-update version-skew window (#4341), and the client recovers from it with an automatic reload (#4408). One pod does not close the window, because the default rolling update surges a new pod before the old one stops. |
processes.api.workers | 1 | Uvicorn worker processes per API pod (--workers). 1 is the shipped image’s own single-process behavior, unchanged. Each additional worker is its own process (~160 MiB resident, measured) counted against resources.api.limits.memory, and holds its own persistent PostgreSQL connection (CONN_MAX_AGE=600 under ATOMIC_REQUESTS in trueppm_api.settings.prod) — so replicaCount x processes.api.workers counts against PostgreSQL’s max_connections (#2275). On Kubernetes, replicaCount buys the same throughput and redundancy besides, which a second worker in the same pod does not — prefer replicas; see Raising the uvicorn worker count. Must be >= 1; the render fails rather than passing 0 through to uvicorn. |
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> (for example v0.4.0-beta.4) — the tag form on the default GitLab registry, where the v is part of the tag, not decoration. GHCR uses the bare version instead; see Image tags differ by registry. Override per-deploy with a concrete tag, which is used verbatim. Floor: a web image published after #4359 lands. A web image from v0.4.0-beta.7 or earlier has no asset-manifest.json at its html root (the readiness probe’s path added by #4359), so pinning one 404s the web pod’s readiness forever — see Upgrading across the #4359 readiness-probe change. |
image.pullPolicy | IfNotPresent | Standard Kubernetes pull policy. |
Image tags differ by registry
Section titled “Image tags differ by registry”The same release is published to two registries under two tag forms:
| Registry | Repository | Tag for release 0.4.0-beta.4 |
|---|---|---|
| GitLab Container Registry — the chart’s default | registry.gitlab.com/trueppm/trueppm/{api,web} | v0.4.0-beta.4 |
| GHCR | ghcr.io/trueppm/{api,web} | 0.4.0-beta.4 |
With image.tag empty the chart resolves v<appVersion>, which is right for the
default repository and wrong for GHCR. If you point image.repository and
image.webRepository at GHCR, also set image.tag to the bare version. A mismatch
surfaces as ImagePullBackOff on the migrate init container with manifest unknown. A mirror that retags may use either form — set image.tag to whatever
yours carries. See Image pull failure.
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. |
service.web.type / service.web.port | ClusterIP / 80 | Web Service, rendered while web.enabled. Exposing this one, not the API Service, is the safe way to publish a demo. |
web.containerPort | 8080 | Port the unprivileged nginx image listens on (satisfies runAsNonRoot). The web Service targets it by name. |
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 |
|---|---|---|
env.TRUEPPM_DJANGO_ADMIN_ENABLED | "false" | Added in 0.4. Whether the API serves /admin/ at all, independent of the nginx rules below. See Configuration. |
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.referrerPolicy | strict-origin-when-cross-origin | Referrer-Policy. Without it, a user navigating from a TruePPM page to any other site hands that site the full URL they came from, and the browser default varies by vendor and version. The default keeps the full referrer for same-origin requests and sends only the bare origin cross-origin; tighten to no-referrer to disclose nothing at all. Set "" to omit the header only when an upstream proxy already sets it. |
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 |
|---|---|---|
processes.worker.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. |
processes.worker.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. |
processes.worker.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. With demo.enabled the chart refuses to render unless every path uses service: web. |
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. |
valkey.podDisruptionBudget.enabled | true | PDB on the bundled Valkey, maxUnavailable: 0, mirroring postgresql.podDisruptionBudget.enabled above — same non-HA caveat, same drain-blocks-instead-of-silently-evicts effect. Valkey is load-bearing for the Channels layer, the Celery broker, the cache backend, and notification throttles at once, so an unannounced eviction takes all four down together. Override with kubectl drain --disable-eviction. |
valkey.priorityClassName | "" | A PriorityClass you have created, so Valkey is not the first thing evicted under pressure. |
valkey.terminationGracePeriodSeconds | 60 | Time for Valkey to finish its AOF rewrite/fsync and shut down cleanly. The 30s Kubernetes default can cut a large AOF flush short and force a slower AOF-log replay 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. Also renders default-deny ingress policies for api/web/celery-worker/celery-beat (see below) — independent of the bundled-subchart toggles, so it applies on the managed-datastore (production) path too. Requires a policy-enforcing CNI (Calico, Cilium, Antrea, …) — silently unenforced without one. |
networkPolicy.ingressControllerSelector | namespaceSelector matching kubernetes.io/metadata.name: ingress-nginx, empty podSelector | The NetworkPolicyPeer that the api/web default-deny ingress policies admit for your ingress controller. They also admit each other where the chart’s own topology needs it: web’s nginx proxies to api, the helm test probe calls api directly, and on the share-link demo (demo.enabled with demo.interactive off) the share-link helm test hook calls web. The peer is rendered verbatim, so any peer shape works. For a cloud load balancer, use an ipBlock naming its source ranges. Helm merges your override into the default, so an ipBlock override must also set namespaceSelector: null and podSelector: null. An empty selector refuses to render, because a peerless rule admits every source. While this is left at the default on k3s or RKE2, kube-system is also admitted, since both distributions run their bundled controller there. A controller on hostNetwork needs an ipBlock for the node addresses instead. Otherwise a mismatch silently blackholes ingress traffic. celery-worker and celery-beat admit nothing, because neither has a Service. Egress is untouched by any of this; see the caveat above networkPolicy.enabled. |
networkPolicy.ingressControllerConfirmed | false | Confirms that the default ingressControllerSelector is right for your cluster. It matters on the one helm upgrade that first adds the api/web ingress policies (from 0.4.0-beta.3 or earlier, or after networkPolicy.enabled was false) — that upgrade refuses to render while the selector is still the default and this is false, instead of risking a site-wide outage — and on a fresh install with no chart-rendered Ingress when demo.enabled is true or service.web.type is LoadBalancer/NodePort, since both bypass any in-cluster ingress controller entirely and the default selector is simply wrong for them, not merely unconfirmed. See Upgrading to 0.4 and the demo exposure notes below. |
networkPolicy.monitoringSelector | {} | An optional NetworkPolicyPeer admitted to the api pod’s http port for in-cluster monitoring: the Prometheus scrapes of /api/v1/health/dead-letter/ and /api/v1/health/email/, and the Blackbox probe of /api/v1/health/beat/ described in Observability. When it is empty, those requests are dropped, and the dead-letter and beat alerts go quiet instead of firing. Scrapes through your public hostname pass the ingress controller and need nothing. Example: {"namespaceSelector":{"matchLabels":{"kubernetes.io/metadata.name":"monitoring"}}}. |
networkPolicy.dnsSelector | namespaceSelector matching kubernetes.io/metadata.name: kube-system, empty podSelector | The {namespaceSelector, podSelector} peer the demo.interactive egress-deny policies (below) resolve DNS through. Only rendered — and only consulted — while demo.interactive is true; every other deployment shape leaves api/celery-worker egress open. Matches the whole kube-system namespace rather than a specific CoreDNS pod label, because k8s-app: kube-dns is conventional but not guaranteed across distributions. Override it if your cluster runs DNS somewhere else. |
podSecurityContext | runAsNonRoot: true, runAsUser: 1000, fsGroup: 1000 | Pod-level restricted defaults, applied to every workload. fsGroup makes the kubelet give a mounted PersistentVolume (persistence.media, backup.persistence) group 1000 with group-write, so the non-root process can write a volume a CSI driver provisioned root:root. It changes volume ownership only, not process identity. On OpenShift set fsGroup: null (see OpenShift Deployment). |
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 — was added 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 were added 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 (added in 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 | Added 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 | Added in 0.4. Initial-delay and period tuning for the web (nginx) tier’s 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 path is not a value — readiness checks GET /asset-manifest.json (a build-specific file proving the html root holds THIS image’s own build, not a proxy-upstream check — see web (nginx)), while liveness stays on GET / (#4359). |
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 image.webRepository 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. |
The web tier also bakes a fixed worker_shutdown_timeout 20s; into the
image’s nginx.conf (#4359), not a chart value. Only the chart’s PRODUCTION
web ConfigMap branch (configmap.yaml’s else block) proxies /ws/ with
proxy_read_timeout 86400s — both demo branches return 404 for /ws/
instead and carry no WebSocket traffic either way. Whether that production
/ws/ proxy ever sees live traffic depends on topology:
ingress.hosts’s documented default routes /ws straight to the
api Service, bypassing the web pod’s nginx entirely; this directive matters
on a topology that instead routes /ws through the web tier (no Ingress, or
one configured to do so). On such a topology, the base image
(nginx-unprivileged) stops on SIGQUIT — a graceful shutdown that, with no
bound, waits for open connections to close on their own — so a terminating
pod with live WebSocket viewers held those sockets open until the full
termination grace ran out and Kubernetes SIGKILLed it. Either way the client
sees an abrupt close (code 1006; nginx does not send a WebSocket close
frame on this path), so the fix is not about the close code — it is a bounded
drain time: nginx now closes those connections itself within 20s instead of
potentially riding out the full remaining grace, so the pod’s own shutdown
(and the next rollout step) is not held hostage to however long a client
happens to stay connected. 20s leaves a 5s margin inside the 25s that remains
after lifecycle.web.preStopSleepSeconds (5s) is subtracted from
lifecycle.web.terminationGracePeriodSeconds (30s). worker_shutdown_timeout
is a main-context nginx directive — it cannot live in the chart-mounted
default.conf, which is included inside nginx’s http {} block — so it is
set once, in the trueppm/web image’s own nginx.conf.
This reaches Helm (and a bare docker run of the image) but not Docker
Compose. Under the chart, the web Deployment’s container runs this image’s
nginx directly, so the fix applies. Under both docker-compose.prod.yml and
docker-compose.demo.yml, the trueppm/web image is used only by the
one-shot web-build init container (cp -r /usr/share/nginx/html/. /dist/),
which copies the static bundle out and exits — its nginx process, patched
directive and all, never starts, in either stack. The container that
actually serves traffic is a separate, unmodified nginx:1.27-alpine image
in both — but only docker-compose.prod.yml’s has the underlying bug: it
renders nginx/app.conf.template or app-http.conf.template (picked by
init-prod.sh’s TLS mode), both of which proxy /ws/ with the same
unbounded proxy_read_timeout 86400s and no worker_shutdown_timeout.
docker-compose.demo.yml renders nginx/demo.conf.template, whose /ws/
already return 404s — the same no-WebSocket-traffic posture as the chart’s
own demo branches, so there is nothing to fix there. Tracked in
#4366, scoped to
docker-compose.prod.yml only. docker-compose.yml (dev) is unaffected
either way: its web service runs the Vite dev server, not nginx.
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.
Rollout strategy
Section titled “Rollout strategy”rollout.<tier> (api, worker, web) exposes the Deployment-level knobs the
closed schema used to refuse outright: strategy, maxSurge,
maxUnavailable, minReadySeconds, and progressDeadlineSeconds. Every
field ships empty or null and renders nothing until set, so a stock install
stays byte-identical: all three tiers still take Kubernetes’ own Deployment
defaults (RollingUpdate 25%/25%, minReadySeconds: 0,
progressDeadlineSeconds: 600) exactly as before this knob existed.
| Key | Default | What it does |
|---|---|---|
rollout.api.strategy | "" | "" follows the Kubernetes default (RollingUpdate). Set to "RollingUpdate" or "Recreate" to render the block explicitly. |
rollout.api.maxSurge | "" | Int or percent string (e.g. "25%"). Only meaningful under RollingUpdate. Empty tracks whatever Kubernetes’ own default is rather than the chart restating it. |
rollout.api.maxUnavailable | "" | Same shape as maxSurge. |
rollout.api.minReadySeconds | null | null omits the field (Kubernetes default 0). An explicit 0 is a distinct, real setting — accept a replacement pod the instant it reports ready — and renders rather than being treated as unset. |
rollout.api.progressDeadlineSeconds | null | null omits the field (Kubernetes default 600). |
rollout.worker.* | same shape | Same five keys, independent of the api tier. |
rollout.web.* | same shape | Same five keys. Set strategy: Recreate (or maxSurge: 0 on a single web pod) here to trade a full web outage for the length of every rollout for zero version skew. Under the default RollingUpdate, #4341’s prior-release-asset retention covers tabs opened before the upgrade. The other direction, a new index.html asking an old pod for a new file, stays open for the rollout and the client recovers from it with an automatic reload (#4408). |
strategy: Recreate cannot carry maxSurge/maxUnavailable — Kubernetes
has no surge/unavailable concept for a tear-down-then-start-up rollout, and
the chart fails the render rather than silently drop one half of an
inconsistent combination.
Celery Beat has no rollout.beat entry. It is a pinned singleton that
already ships a fixed strategy: Recreate directly in its own Deployment
template — a settable knob would let an operator flip it to RollingUpdate,
which briefly runs two Beats and double-fires every periodic task. Its
existing lifecycle.beat.* knobs (above) already cover how fast the single
replacement pod is accepted; there is nothing else on a strategy that never
surges to tune.
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 once a tier runs 2+ pods; beat is excluded (pinned singleton). Under values-prod.yaml the web tier runs two pods, so its PDB applies. Under values-demo.yaml, which pins replicas.web: 1, it is a no-op. 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 processes.worker.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. Required before inviting anyone by email — a workspace-invite message has no other way to reach the invited user, so with this unset the email omits the accept link entirely rather than one that resolves nowhere; other emails (task deep-links, password reset, export-ready) degrade more gently, and the OIDC callback falls back to a same-origin relative redirect. Three things warn when it is unset: the API process logs a warning at boot (settings/prod.py, so a non-Helm deploy is still told), manage.py check --deploy reports it too, and the chart’s post-install NOTES carry it. See Outbound Email. |
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.CSRF_TRUSTED_ORIGINS | "" | Comma-separated scheme+host origins Django’s CSRF middleware trusts, e.g. https://trueppm.example.com. Required whenever the web app’s origin differs from the API’s (a split-origin Ingress such as app.example.com + api.example.com) — without it, SSO login succeeds and sets the refresh cookie, then the SPA’s post-login refresh request 403s with a CSRF failure. Set it to the same origin(s) as env.TRUEPPM_FRONTEND_BASE_URL. Harmless to leave empty on this chart’s default single-origin Ingress. |
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. 1 suits the default ingress.hosts (/api straight to the API Service); routing through the web tier is two hops and needs 2 — values-demo.yaml sets that, since demo mode cannot bypass the web tier. A cloud load balancer or CDN in front of the ingress controller changes the count too (see Networking). Too low collapses the per-IP throttles into one shared bucket; too high lets clients spoof X-Forwarded-For. Check the value with security.client_address on /api/v1/health/system/. |
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.emailTransportFor | 5m | How long trueppm_email_transport_unavailable (a stored SMTP credential that will not decrypt — served by /api/v1/health/email/, its own scrape job, see Observability) must read true before TruePPMEmailTransportUnavailable fires (critical — no outbound email is sending at all). |
alerts.thresholds.emailFailed / emailFailedFor | 0 / 10m | Threshold and hold time for TruePPMEmailDeliveryFailing — recent failed sends above the threshold with zero recent deliveries, meaning the relay is rejecting everything rather than one bad recipient. |
alerts.thresholds.emailQueueAging / emailQueueAgingFor | 0 / 15m | Threshold and hold time for TruePPMEmailQueueAging — queued notifications sitting unsent, distinct from the two above: sends are not even being attempted (worker gone, or a transport that cannot be built). |
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. Must be greater than 0 — helm upgrade refuses 0 or a negative number, since either would make the alert fire permanently from the first successful run onward rather than express a real tolerance. |
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.demoReset.jobFailedFor | 20m | How long the most recent scheduled reset must stay unsuccessful before TruePPMDemoResetFailed fires. A run in progress counts until it finishes, so keep this above demo.reset.activeDeadlineSeconds. It clears when the next run succeeds. Rendered only when demo.enabled and demo.reset.enabled. |
alerts.thresholds.demoReset.staleAfterSeconds | 93600 | Age (seconds) of the last successful reset that fires TruePPMDemoResetStale. 26h = one full day plus slack over the default daily schedule, so one missed run is tolerated and the alert does not fire against its own cadence. Raise it if you lengthen demo.reset.schedule. Must be greater than 0 — helm upgrade refuses 0 or a negative number, since either would make the alert fire permanently from the first successful run onward rather than express a real tolerance. |
alerts.thresholds.demoReset.staleFor | 30m | How long staleAfterSeconds must stay breached before the alert fires. |
alerts.thresholds.demoReset.neverSucceededFor | 26h | How long the “no successful reset has ever been recorded” condition must hold before TruePPMDemoResetNeverSucceeded fires. Must exceed one full schedule period plus slack — 26h suits the daily default. |
alerts.thresholds.volumeAvailablePercent | 15 | Free-space percentage below which TruePPMVolumeFillingUp starts its clock, for every claim in the namespace — database, Valkey, backups, media. Set to 0 to disable this rule specifically (the comparison becomes unsatisfiable) while leaving every other alerts.thresholds.* rule, and alerts.enabled, untouched — there is no separate per-rule enable/disable flag. |
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. Added 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.successfulJobsHistoryLimit / failedJobsHistoryLimit | 3 / 3 | How many completed / failed backup Jobs Kubernetes keeps around for kubectl logs inspection before garbage-collecting them. |
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. Credentials are passed as AWS_* env vars rather than assembled into a URL, because a real secret key routinely contains /, +, or :. |
backup.s3.allowPlaintext | false | Silences the warning the job logs when backup.s3.endpoint is http:// and does not look like an in-cluster or private-network address — the dump would otherwise cross the network unencrypted. It never fails the job either way; set this only once you have verified the path is actually trusted. |
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. Written on exactly one API pod (the first to bootstrap) — retrieve it by checking each pod, as in Admin password setup. |
admin.email | "" | Bootstrap admin email. Set it — left empty the bootstrap uses admin@example.com, a reserved domain that cannot receive password-reset mail. |
admin.promoteExisting | false | Allow the bootstrap to promote an existing non-superuser account holding admin.email when no superuser exists. Off by default; promotion resets the password and revokes that account’s tokens and grants and unlinks its SSO identities. |
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.
A request for the bare origin (/) is answered with a 302 to the schedule share
link, because the demo has no accounts and the app’s login form could never succeed.
| Value | Default | Effect |
|---|---|---|
demo.enabled | false | Master switch. Everything else in the block is inert while false. |
demo.interactive | false | A separate switch from demo.enabled, and the two do not imply each other. It arms DemoReadOnlyMiddleware on the API pod (TRUEPPM_DEMO_READ_ONLY=true), which refuses every unsafe HTTP method under /api/ except sign-in, token refresh and sign-out — regardless of the caller’s role — so a demo may publish a login and still be read-only. demo.enabled alone renders exactly what it always did. It also renders a third nginx server block (a method fence over /api/, /admin/ and /ws/ still 404), an egress-deny NetworkPolicy for the api and celery-worker pods, and re-aimed login/account throttles — see Interactive demo mode for the full posture, including what it does not protect against (a hostname gate in front of it, the scheduled reset, and host isolation from your own CI runners). |
demo.loginHint.username | "" | The published account. Required when demo.interactive is true, and rejected when it is false. Must name an account the loaded sample seeds — atlas-visitor for the bundled Atlas pack, a Member on the program and all three projects that holds no work and no ADMIN/OWNER role anywhere. The seed refuses to password a staff/superuser row, a row holding ADMIN/OWNER or workspace admin, or a row that does not look like one this sample created. Letters, digits and . _ @ + - only: the value is written into the verification hook’s request body. |
demo.loginHint.password | "" | The published password, 8–128 characters of letters, digits and . _ @ + ~ ! * = : , ; ? -. It is deliberately not derived from TRUEPPM_DEMO_PASSWORD (the --with-personas persona password), so a mis-set mode flag on a real install cannot print a live credential. It reaches the API pod inside TRUEPPM_DEMO_LOGIN_HINT on purpose — it is published by design and is not a secret. Never point this at an instance with real users. |
demo.throttle.loginAccountRate | "1000/min" | Only rendered while demo.interactive is true, as TRUEPPM_THROTTLE_LOGIN_ACCOUNT_RATE, unless env.TRUEPPM_THROTTLE_LOGIN_ACCOUNT_RATE is already set (that wins). Raises the per-account login-lockout scope (ordinarily 5/min) for concurrency rather than credential-stuffing resistance — the credential is published, so there is nothing left for that scope to protect. The per-IP login scope stays untouched and is the real limiter. |
demo.throttle.userRate | "60000/min" | Only rendered while demo.interactive is true, as TRUEPPM_THROTTLE_USER_RATE. In that mode it replaces env.TRUEPPM_THROTTLE_USER_RATE, which the chart otherwise always sets — tune the demo’s rate here, not in env. Raises DRF’s per-account "user" throttle scope (ordinarily 1000/min) so a crowd of visitors sharing the one published account cannot throttle each other’s ordinary reads. This is the only throttle bounding a signed-in visitor’s reads — the per-IP login/anon scopes stop applying once authenticated — so it is a shared-fate budget, not a per-visitor fairness control; a client that authenticates and issues requests aggressively can still consume the whole thing. The default is set high enough to be effectively lifted: a shared bucket fails every visitor at once when it trips, so it cannot protect anyone, and the real bounds are the api pod’s capacity and per-IP limiting at the edge in front of the host. |
demo.throttle.maxPersonalAccessTokens | 10 | Only rendered while demo.interactive is true, as TRUEPPM_MAX_PERSONAL_ACCESS_TOKENS, unless env.TRUEPPM_MAX_PERSONAL_ACCESS_TOKENS is already set. Left at its ordinary default rather than re-aimed: the write fence does not allow-list personal-access-token creation, so this cap is never reached in this mode. Rendered anyway so the decision is explicit. |
demo.baseUrl | "" | Public origin — scheme, host and optional port, such as https://demo.example.com. Required when enabled; it cannot be inferred from inside the cluster. A single trailing slash is trimmed. A path, a query, or any other character is rejected at render time, because the value is written into the demo nginx config for the / redirect. |
demo.shareToken.schedule | "" | Pinned token for the schedule link, and the target of the / redirect. Required when enabled. Letters, digits, - and _ only — anything else is rejected at render time because the token is written into the nginx config. The generator command below always satisfies this. |
demo.shareToken.board | "" | Pinned token for the board link. Required when enabled, must differ from the schedule token, and follows the same character rule. |
demo.backoffLimit | 2 | Seed Job retries. Exhaustion fails the release deliberately — a demo without data is broken. |
demo.activeDeadlineSeconds | 720 | Kills a hung install/upgrade seed hook. Sized between migrate_locked’s own lock-wait (600s/10m default) and the --timeout 15m recommended below — see Demo-mode upgrades. Without it a stuck hook could outlast Helm’s default 5-minute --timeout, which already marks the release failed after the api/web/worker Deployments have rolled. |
demo.resources | see values.yaml | Requests/limits for the short-lived seed Job (and the reset CronJob’s pod). |
demo.reset.enabled | false (true in values-demo.yaml) | Adds a CronJob that re-runs the install hook’s seed on a schedule so the sample’s dates do not age. Off by default because it is destructive on a timer. Inert while demo.enabled is false. |
demo.reset.schedule | 0 8 * * * | Five-field cron, or an @hourly, @daily, @weekly, @monthly or @yearly macro, in the CronJob controller’s time zone (UTC on managed clusters). Anything else is rejected at render time. Once a day by default (#4151) — the reset’s job is mainly re-anchoring the sample’s dates (ADR-1197 D9), and the interactive mode’s write refusal already leaves little else for it to clean up. While demo.interactive is true this value also reaches the api pod as TRUEPPM_DEMO_RESET_SCHEDULE, surfaced on GET /api/v1/edition/ as demo_reset_schedule so the shell’s demo bar can state “resets daily at HH:MM UTC” from it. Raise the reset alert thresholds below if you lengthen it. |
demo.reset.startingDeadlineSeconds | 600 | A run that cannot start within this many seconds of its slot is skipped, not queued, so a controller outage does not fire a burst of resets afterward. |
demo.reset.activeDeadlineSeconds | 900 | Kills a hung seed. Overlapping runs are forbidden, so a hung run would otherwise block every later one. |
demo.reset.successfulJobsHistoryLimit / failedJobsHistoryLimit | 1 / 3 | Finished Jobs kept for kubectl logs: the last success (its log carries the share URLs) and the last failures (the traceback). |
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 '=+/')" \ --set-json 'networkPolicy.ingressControllerSelector={"namespaceSelector":{"matchLabels":{"kubernetes.io/metadata.name":"cloudflared"}},"podSelector":{}}'The last line is required from 0.4.0-beta.4: without it the chart refuses to render.
Replace cloudflared with the namespace your tunnel client runs in; the exposure notes
below explain why and cover a tunnel running on the host.
Scheduled reset
Section titled “Scheduled reset”With demo.reset.enabled the chart adds a CronJob that repeats the install hook’s seed
once a day, so the sample keeps reading as a program in flight (the seed anchors its
dates to the day it runs, so without a refresh it ages). values-demo.yaml turns it on.
It is not a security control: it changes how long stale data can stay visible, nothing
more.
Three consequences to plan for:
- The share URLs return 404 for a few seconds each run. The seed’s delete cascades the links away and the second command recreates them at the same pinned URLs.
- Assume project and program ids change every run. The share URLs survive because
their tokens are pinned; a bookmark or open tab on
/projects/<id>does not. - A failed run does not fail the release. The failed Job is kept for
kubectl logs job/<release>-trueppm-demo-reset-<timestamp>, and withalerts.enabledtheTruePPMDemoResetFailed,TruePPMDemoResetStaleandTruePPMDemoResetNeverSucceededalerts fire. A failed reset leaves the demo serving its previous data, which is degraded, not unsafe.
Three 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.
- The bare domain redirects to the schedule link. A visitor who types the origin
is sent (
302) to<baseUrl>/share/schedule/<token>. It is a302, not a301, so rotating the pinned token is never fought by a browser’s cached redirect. Only the exact path/redirects; the share URLs and every other path behave as before, and the redirect discloses nothing beyond the link the demo already publishes. - 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.
Expose the web Service, never the API. The read-only allowlist is the web tier’s
nginx, so only traffic that enters through the web Service is constrained. Pointing a
tunnel such as Cloudflare Tunnel at svc/<release>-trueppm-web is the intended path;
the chart does not bundle one. Two combinations would publish the whole authenticated
API and /ws/ around the allowlist, so the chart refuses to render them:
demo.enabledwithingress.enabled, unless every Ingress path setsservice: webandweb.enabledis true. The defaultingress.hostsroute/apiand/wsstraight to the API Service, so an Ingress left at its defaults is rejected.demo.enabledwith an API Service (service.type) other thanClusterIP. ALoadBalancerorNodePortthere is reachable with no Ingress at all. To publish a demo through a load balancer, setservice.web.typeinstead.
Getting past those two refusals is not the same as being reachable. This overlay renders
no Ingress, so networkPolicy.ingressControllerSelector’s default (a namespace called
ingress-nginx) is the peer the app-tier default-deny NetworkPolicy admits — and a
tunnel client such as cloudflared is not an ingress controller and does not run in
that namespace. Left at the default, the chart refuses to render at all: on a fresh
install, not only an upgrade (#4003), because there is no existing policy for the
upgrade-only transition guard above to have already let through. Point the selector at
the namespace your tunnel actually runs in:
--set-json 'networkPolicy.ingressControllerSelector={"namespaceSelector":{"matchLabels":{"kubernetes.io/metadata.name":"cloudflared"}},"podSelector":{}}'The same guard applies to service.web.type: LoadBalancer/NodePort: that traffic
arrives from client or node IPs, not from a pod in the ingress controller’s namespace,
so it needs an ipBlock peer naming the load balancer’s or node’s source range instead
— set the same networkPolicy.ingressControllerSelector key to an ipBlock shape. In
either case, networkPolicy.ingressControllerConfirmed=true is the escape hatch if you
have reviewed the exposure and the default selector is genuinely fine (for example, a
service mesh or an external firewall already restricts the Service). If cloudflared
runs on the host rather than as an in-cluster pod, host-to-pod reachability under your
CNI is not something the chart can verify — confirm it yourself before relying on it.
Five more refusals guard the interactive mode’s published credential and its edge hardening:
demo.loginHintset while neitherdemo.interactivenordemo.enabledis true. The write fence is then off, so the hint would publish a working login to a writable instance — any authenticated user mayPOST /projects/and is made its owner.demo.loginHintset whiledemo.interactiveis false. Nothing reads it, so the result is a demo with no login and no diagnostic.demo.interactivetogether withenv.TRUEPPM_DEMO_PASSWORD. That variable is the persona-login password:load_sample_project --with-personasgives it to every persona in the sample and refuses only staff and superuser rows, so personas holdingOWNERorADMINreceive it too. A published demo credential must open exactly one unprivileged account. The seeder refuses the flag outright in this mode as well.demo.interactivetogether withpersistence.media.enabled. This mode must give theapipod no writable media path at all — defense in depth over the write fence, which already refuses the upload. The bundled Atlas sample’s attachments are external-link references, not uploaded files, so nothing is lost by the restriction.demo.interactiveandnetworkPolicy.enabledtogether withpostgresql.enabledorvalkey.enabledset tofalse. The egress-deny policy for this mode allows only DNS and the chart’s own bundled datastore pods — it has no rule for a managed/external database, so rendering it there would cutapiandcelery-workeroff from their own data store. Enable the bundled subcharts to use this mode, or setnetworkPolicy.enabled: falseand restrict egress at the platform layer instead.
Verify an interactive install with helm test <release>: the hook signs in with the
hint and asserts that an authenticated POST /api/v1/projects/ answers 403 with
code: "demo_read_only", so a missing seed, a wrong overlay or an unarmed middleware
fails the release. It then repeats the write through the web Service and asserts the
same 403 and demo_read_only code with a JSON content type, so the edge fence is proven
to answer in the shape the web app recognizes. Both calls use in-cluster DNS, so nothing
in front of the web Service (Ingress, tunnel, access gate) is checked.
helm test also verifies the share-link overlay itself, not only the interactive
one. demo.enabled with demo.interactive left off — the overlay values-demo.yaml
ships — renders its own hook that curls both share URLs
(/api/v1/share/schedule/<token>/, /api/v1/share/board/<token>/) through the web
Service, never the API Service, and asserts each answers 200 with a non-empty body.
Reaching them through the web Service exercises the nginx allowlist
(location ^~ /api/v1/share/) rather than bypassing it, so the hook also proves that
allowlist is still proxying the two URLs it exists to serve. The tokens it checks come
from the same demo.shareToken.schedule / .board values the seed hook passes to
create_demo_share_link, so a token that was pinned but never actually seeded — or a
seed that produced a different one — fails the test instead of a demo that is up,
green, and silently blank. The two hooks are gated independently and never render
together: this one needs demo.interactive to stay false, exactly as
values-demo.yaml leaves it.
The guard sees only what the chart renders. It cannot see an Ingress you create
yourself, or controller annotations under ingress.annotations that reroute traffic
behind the chart’s back — for example ingress-nginx’s default backend pointing at the
API Service — so keep both out of a demo.
Attachments use scratch space, not a volume. Demo mode opts in to local
attachment storage (the trueppm-env Secret sets
TRUEPPM_ALLOW_LOCAL_ATTACHMENT_STORAGE=true), and that opt-in makes every pod that
boots Django prove MEDIA_ROOT is writable. Pods run with a read-only root
filesystem, so values-demo.yaml sets env.TRUEPPM_MEDIA_ROOT: /tmp — each pod’s
scratch emptyDir. A demo has no accounts and no write path, so nothing is ever
stored there, and anything that were would vanish with the pod. Do not swap this for
persistence.media: the seed Job does not
mount that claim, so the install would still fail on the seed hook. If you write your
own values file rather than layering on values-demo.yaml, carry the key over —
without it every Django pod refuses to start (see Troubleshooting).
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.
- OpenShift Deployment —
podSecurityContext/containerSecurityContextunder therestricted-v2SCC, and Ingress→Route conversion.