Optional and advanced settings
Optional / advanced settings
Section titled “Optional / advanced settings”| Variable | Default | Description |
|---|---|---|
JWT_SIGNING_KEY | SECRET_KEY | Dedicated signing key for access/refresh JWTs. When unset it inherits SECRET_KEY, so a single strong value covers everything. Set it to a separate strong value (same rules as SECRET_KEY — ≥ 32 chars, not the django-insecure- placeholder, or prod refuses to boot) to limit the blast radius of a SECRET_KEY leak and to gain an independent rotate-to-sign-everyone-out lever. See Secret management. |
TRUEPPM_REFRESH_TOKEN_REMEMBER_DAYS | 30 | Session length when a user checks “Keep me signed in” at login: a persistent refresh cookie (survives browser close) and matching token lifetime, in days. |
TRUEPPM_REFRESH_TOKEN_SESSION_HOURS | 12 | Sliding idle lifetime for a not-remembered login (the default, and all SSO logins): the refresh cookie is a session cookie (dropped on browser close) and the token expires after this many idle hours. |
TRUEPPM_EDITION | community | Edition discriminator read by /api/v1/edition/. Set to enterprise in the enterprise Helm chart so the React shell can make the post-login redirect decision without importing enterprise code (ADR-0029). Never set this in an OSS deployment. |
TRUEPPM_VERSION | the installed package version | Version string surfaced on /api/v1/edition/; will also feed the in-product feedback payload once that control shipped in 0.4, so a bug report names the exact release it came from. Leave unset to use the running package’s own version. |
TRUEPPM_BUILD_SHA | (empty) | Commit SHA of the image build, surfaced alongside TRUEPPM_VERSION. Set by the Helm chart’s image build pipeline; empty on a source checkout, where the version string alone is identifying enough. |
TRUEPPM_SECURE_SSL_REDIRECT | false | Production-only HTTP→HTTPS redirect. See TLS redirect posture below before enabling it. |
TRUEPPM_PUBLIC_API_BASE_URL | (empty) | Public origin of the API itself (not the web app). Pins the two absolute URLs TruePPM would otherwise derive from the request’s Host header: the OIDC redirect_uri your identity provider redirects back to ({TRUEPPM_PUBLIC_API_BASE_URL}/api/v1/auth/oidc/callback/), and — from 0.4 — the inbound Git-webhook URL a project admin pastes into GitHub/GitLab. Empty (the default) falls back to the incoming request’s absolute URL, correct only when the SPA and the API are genuinely reached at one origin — not true of the bundled local development stack (docker-compose.yml, make up), whose Vite dev proxy rewrites Host on the way through exactly like a reverse proxy would, so that stack pins this explicitly rather than relying on the default. Behind any proxy or load balancer that rewrites Host — including the dev-stack Vite proxy — set this explicitly; TruePPM ignores X-Forwarded-Host by design. A trailing slash is stripped. See Single sign-on. |
TRUEPPM_EGRESS_ALLOWLISTED_HOSTS | (empty) | Comma-separated list of hostnames exempt from TruePPM’s outbound SSRF address deny-list, which otherwise blocks private, loopback, and link-local addresses. Set this if you run your identity provider inside the same cluster — an in-cluster Keycloak or Authentik at a private service address (e.g. keycloak.sso.svc.cluster.local) is unreachable without it, so discovery, token exchange, and JWKS all fail with “provider unreachable” and Test connection reports the issuer as unreachable. Matching is an exact, case-insensitive hostname compare — no wildcards, no suffix matching. The exemption applies to every outbound integration, not only SSO, so list the specific IdP host and nothing broader. In the Helm chart, set it under env: in values.yaml, where it is documented alongside TRUEPPM_INTEGRATION_ALLOWED_HOSTS, or inject it through envFrom (a ConfigMap or Secret key); there is no dedicated chart value. See Single sign-on → Running the identity provider inside your cluster. |
TRUEPPM_HISTORY_RETENTION_DAYS | 90 | How many days of object-change history to keep. Records older than this are purged nightly by Celery beat. To disable automatic purging, set the Django setting to None in a settings override or toggle the table off in the Retention & purge editor. Do not set 0 — a zero-day window makes the cutoff “now” and purges all rows on the next run. The legacy bare HISTORY_RETENTION_DAYS is still read as a fallback when the prefixed var is unset. |
TRUEPPM_TASK_RUN_RETENTION_DAYS | 30 | How many days of completed/failed/canceled Celery task-run records to keep before the nightly purge. To disable, set the Django setting to None in a settings override or toggle the table off in the Retention & purge editor. Do not set 0 — a zero-day window purges all rows on the next run. The legacy bare TASK_RUN_RETENTION_DAYS is still read as a fallback when the prefixed var is unset. |
MSPROJECT_MAX_UPLOAD_MB | 50 | Per-file size cap for MS Project (.mpp / .xml) imports, in megabytes. See MS Project import limit below. |
JIRA_IMPORT_MAX_UPLOAD_MB | 25 | Per-file size cap for Jira XML imports, in megabytes. See Jira import limit below. |
CSV_IMPORT_MAX_UPLOAD_MB | 10 | Per-file size cap for CSV / Excel imports, in megabytes. See CSV / Excel import limits below. |
CSV_IMPORT_MAX_ROWS | 5000 | Maximum data rows a single CSV / Excel import may contain. Rows past the cap are reported back as skipped, not silently dropped. |
CSV_IMPORT_MAX_UNCOMPRESSED_MB | 100 | Decompression-bomb ceiling for .xlsx uploads: the maximum total uncompressed size the workbook may declare. |
TRUEPPM_SCHEDULE_TASK_CEILING | 1000 | The Schedule’s tested-comfortable task count (#3388). Distinct from the CSV_IMPORT_MAX_ROWS file cap above — a file well under that cap can still carry a project past this line. Advisory only: it drives the CSV/Excel import preview’s warning and the Schedule’s own past-the-ceiling banner, and never blocks anything. Lower it if your hardware is slower than the reference machine the ceiling was measured on; raise it if load-testing your own workload shows more headroom. |
TRUEPPM_THROTTLE_ANON_RATE | 60/min | General default rate limit for unauthenticated requests, per client IP, in DRF <count>/<period> form (period is sec, min, hour, or day). Applies to every endpoint that does not set its own throttle. See general API rate limiting below. |
TRUEPPM_THROTTLE_USER_RATE | 1000/min | General default rate limit for an authenticated account, in DRF <count>/<period> form. Applies to every endpoint that does not set its own throttle. See general API rate limiting below. |
TRUEPPM_THROTTLE_READYZ_RATE | 2000/min | Rate limit for the unauthenticated readiness probe (GET /api/v1/readyz), per client IP, in DRF <count>/<period> form. Its own scope rather than the shared anon bucket — see general API rate limiting below. |
TRUEPPM_NUM_PROXIES | 1 | Number of trusted reverse proxies in front of the API. Used to extract the real client IP from the X-Forwarded-For chain for every per-IP rate limit: anonymous requests, login, share links, the readiness probe, and Git webhooks. 1 matches the default ingress.hosts, which routes /api straight to the API Service. Any path that goes through the web tier — including every demo-mode deployment, which has no other option — is two hops and needs 2; a direct-to-API deployment needs 0. A cloud load balancer or CDN in front of the ingress controller changes the count as well. Too low and every visitor shares one throttle bucket; too high and a client can spoof its IP to mint a fresh one. Negative values are refused at startup. See Networking for the hop table, and for how to read back the address a request was keyed on. |
TRUEPPM_RATE_LIMIT_ENABLED | true | Global on/off switch for all API rate limiting. Leave true in production. Setting it false requires an explicit acknowledgment env var and disables every throttle instance-wide (DoS/abuse protection off) — intended for load testing only. See disabling rate limiting entirely below. |
TRUEPPM_THROTTLE_LOGIN_ACCOUNT_RATE | 5/min | Per-account login rate limit, bucketed by hashed username across all source IPs, in DRF <count>/<period> form. Stacks with the fixed per-IP login throttle to bound distributed credential stuffing. See login brute-force protection below. |
TRUEPPM_PUBLIC_BOARD_SHARING_ENABLED | true | Org-wide kill switch for public sharing — governs both board and schedule links. When false, project Admins cannot mint public links (403) and every existing public link stops resolving (404) instance-wide — the operator lever for locked-down environments. No data is exposed until an Admin explicitly creates a link, so the default is on. |
TRUEPPM_SHARE_LINK_DEFAULT_EXPIRY_DAYS | 90 | Days a public share link lasts when the mint request omits expires_at. Applies to API callers — integrations, scripts, MCP clients — since the in-app dialog always sends an explicit choice. An explicit expires_at: null still mints a link that never expires, so standing embeds remain possible; this is a default, not a ceiling. Set to 0 to restore the pre-0.4 behavior in which an omitted expiry meant never. |
TRUEPPM_THROTTLE_SHARE_ACCESS_RATE | 60/min | Rate limit for the unauthenticated public board endpoint (GET /share/board/<token>/), per client IP, in DRF <count>/<period> form. Bounds scraping of a leaked or widely-shared link. |
TRUEPPM_THROTTLE_SHARE_MINT_RATE | 20/min | Rate limit for an Admin minting public board links, per account, in DRF <count>/<period> form. |
TRUEPPM_THROTTLE_SAMPLE_LOAD_RATE | 6/min | Rate limit for loading a bundled demo sample (POST /programs/load-sample/), per account, in DRF <count>/<period> form. Each call rebuilds an entire program — a teardown of the caller’s previous copy plus a full fixture import, run synchronously — so the cap is deliberately tight. Ample for a person clicking Load demo data and trying a couple of samples; raise it only if you are scripting demo environments. |
TRUEPPM_THROTTLE_SEED_IMPORT_RATE | 6/min | Rate limit for importing a JSON seed bundle (POST /programs/import/), per account, in DRF <count>/<period> form. The import returns 202 and the subtree is built by a worker, so this bucket bounds job creation rather than request CPU — paired with the per-program in-flight de-dupe, that is what keeps a burst of imports from becoming a queue backlog. Separate bucket, so loading a demo does not spend an import allowance. Raise it if you bulk-migrate programs through the API. |
TRUEPPM_THROTTLE_SEED_VALIDATE_RATE | 20/min | Rate limit for the seed dry run (POST /programs/import/validate/), per account, in DRF <count>/<period> form. Looser than the import bucket because the dry run never reaches the importer — it parses and validates, then writes nothing. Separate bucket for a usability reason as much as a cost one: iterating on a file until it validates must not spend the allowance for importing the file that finally passes. |
TRUEPPM_THROTTLE_OMNI_SEARCH_RATE | 60/min | Rate limit for the ⌘K global Epic/Story omni-search, per account, in DRF <count>/<period> form. Matches the general user-typeahead rate — snug enough to block a scripted scrape of every title an account can see, loose enough that live typing never trips it. |
TRUEPPM_THROTTLE_RESOLVE_RATE | 120/min | Rate limit for the project/program key resolver (/api/v1/resolve/) and key suggestion (/api/v1/keys/), one bucket per account. It is loose enough for page loads and key checks as you type, and tight enough to stop a script from scanning the key namespace. |
TRUEPPM_THROTTLE_BURN_RATE | 60/min | Rate limit for the burn chart and flow metrics reads (GET /api/v1/projects/{id}/burn/ and GET /api/v1/projects/{id}/flow-metrics/), per account, in DRF <count>/<period> form. Both reconstruct their series from HistoricalTask snapshots, so cost scales with project history even with the per-request window cap; the two endpoints share this bucket since they are the same cost class against the same table. Far above what the Reports page generates on its own — the burn chart hook caches its result for 5 minutes. |
TRUEPPM_THROTTLE_SAMPLE_DOWNLOAD_RATE | 60/min | Rate limit for downloading a bundled sample fixture (GET /programs/samples/{key}/download/), per account, in DRF <count>/<period> form. Looser than TRUEPPM_THROTTLE_SAMPLE_LOAD_RATE because a download only streams a small file off disk and touches no table — but it is still throttled, since it serves a file keyed by a client-supplied string and would otherwise double as an enumeration oracle. |
TRUEPPM_THROTTLE_TELEMETRY_TEST_RATE | 6/min | Rate limit for the admin-only telemetry test-export probe (each call opens a real outbound connection to the configured OTLP collector), per account, in DRF <count>/<period> form. Tight enough that the probe cannot be used to hammer the collector. |
TRUEPPM_THROTTLE_MCP_READ_RATE | 120/min | Baseline rate limit for agent-token (mcp:read) reads on the MCP read surface, per token, in DRF <count>/<period> form. Does not affect human session/JWT traffic, nor a member’s own full-access (legacy:full) personal token, which is bounded by TRUEPPM_THROTTLE_USER_RATE like their session. See MCP read-surface rate limiting below. |
TRUEPPM_THROTTLE_MCP_READ_COMPUTE_RATE | 12/min | Tighter rate limit stacked on the four compute-heavy MCP tools (what-if, latest Monte Carlo, forecast, sprint-forecast), per agent token, in DRF <count>/<period> form. See MCP read-surface rate limiting below. |
TRUEPPM_MCP_ENABLED | true | Instance-wide kill switch for MCP (AI-agent) token access. When false, every mcp:read token read is denied (403) across the whole instance — even for tokens that already exist — while human session/JWT traffic, and a member’s own full-access (legacy:full) personal token, are unaffected. The operator lever for “no agent access on this instance, period.” See MCP read-surface rate limiting below. |
TRUEPPM_MCP_PROGRAM_EXPORT_POLICY | withhold | Whether a member project’s agent-read opt-out withholds a program-level bulk export from an agent token — both the synchronous JSON seed and the async .tar.gz bundle carry every member project’s rows verbatim, and neither can be narrowed, so the choice is serve-or-refuse. withhold (default) refuses an mcp:read token when any member project has opted out; allow treats the export as a program-level artifact governed by the program’s own setting alone. Humans are unaffected either way. An unrecognized value falls back to withhold and is reported by manage.py check --deploy (trueppm.E009). |
TRUEPPM_MAX_PERSONAL_ACCESS_TOKENS | 10 | Maximum number of active personal access tokens a single user may hold at once (not revoked, not expired). Bounds the blast radius of a leaked account. Once at the cap a user must revoke one before minting another. |
TRUEPPM_MAX_USER_PINS | 100 | Maximum pinned projects + programs a single user may hold. This is a navigability cap on the pinned rail and /me/pinned/, not a security control — exceeding it returns 400 with code: "pin_limit_reached". |
TRUEPPM_TOKEN_ISSUANCE_PER_MINUTE | 5 | Per-account rate limit (requests per minute) on the token-mint endpoint. Caps the blast radius of a scripted attacker on a compromised admin session even when RBAC is satisfied. |
TRUEPPM_TOKEN_REVOCATION_PER_MINUTE | 30 | Per-account rate limit (requests per minute) on the token-revoke endpoint. Set well above the issuance cap on purpose: revocation is the containment action, and someone cutting off a leak may legitimately revoke every token they hold in one burst. |
TRUEPPM_TASK_SYNC_STEADY_STATE_LIMIT | 100 | Per-project steady-state rate limit (requests per minute) for the inbound task-sync endpoint, applied after the 60-minute backfill window. |
TRUEPPM_TASK_SYNC_BACKFILL_LIMIT | 1000 | Per-project rate limit (requests per minute) for inbound task-sync during the first 60 minutes after a token is minted, giving a large first import headroom before dropping to the steady-state limit. |
TRUEPPM_SHARE_BOARD_MAX_CARDS | 1000 | Maximum cards in a public board snapshot; a larger board is truncated (the viewer sees a “showing the first N cards” note). |
TRUEPPM_SHARE_SCHEDULE_MAX_TASKS | 1000 | Maximum tasks in a public schedule share snapshot; a larger schedule is truncated the same way as an over-cap board. |
VITE_FEATURE_FLAGS | {} | Build-time JSON blob of feature flag overrides for the React frontend, e.g. '{"some_flag_name":true}'. Set in packages/web/.env or .env.production before npm run build. Per-user localStorage overrides win over this default at runtime. No flag is currently registered against this mechanism — it is generic infrastructure kept dormant for the next one. |
TRUEPPM_DEFAULT_FILE_STORAGE | django.core.files.storage.FileSystemStorage | Backend for task-attachment storage. The local default is ephemeral in a container — uploads are lost on every pod restart, and prod refuses to boot on it (see TRUEPPM_ALLOW_LOCAL_ATTACHMENT_STORAGE). Point this at a persistent object-storage backend for production, e.g. storages.backends.s3.S3Storage, and set TRUEPPM_S3_BUCKET_NAME — see object storage. The image bundles the S3 backend only; naming a backend it cannot import fails startup with a message telling you which package to install. |
TRUEPPM_ALLOW_LOCAL_ATTACHMENT_STORAGE | false | Operator opt-in to run production on the local FileSystemStorage default, when local disk is backed by a persistent volume. prod refuses to boot on local storage unless this is true or TRUEPPM_DEFAULT_FILE_STORAGE is set to a remote backend. From 0.4 the opt-in is verified, not taken on trust: boot also probes TRUEPPM_MEDIA_ROOT by creating and deleting a file there, and refuses to start if it cannot. Before that, the opt-in on a container with a read-only root filesystem booted clean and then failed every upload with EROFS. |
TRUEPPM_MEDIA_ROOT | /var/lib/trueppm/media | Directory everything Django’s default storage writes goes to on local storage — task attachments, the workspace logo, seed-import payloads, and the project / program / workspace export bundles. Shipped in 0.4; before it, Django’s empty default resolved uploads against the process working directory, so the storage location followed whoever started the process rather than the deployment’s configuration. Ignored on object storage. Mount a volume here — docker-compose.prod.yml mounts the named media volume at this path, and the Helm chart mounts persistence.media’s claim (see Helm values). scripts/backup.sh and scripts/restore.sh read the same variable, so setting it is what puts attachments inside the backup artifact. |
STATIC_ROOT | staticfiles/ under the app directory | Filesystem path WhiteNoise collects and serves Django static assets from (manage.py collectstatic). Override only if your container image lays out paths differently than the shipped image. |
TRUEPPM_ATTACHMENT_STORAGE_SIGNS_URLS | false | Operator opt-in confirming TRUEPPM_DEFAULT_FILE_STORAGE produces a real time-limited signed URL. The attachment Get signed download URL action only recognizes the built-in django-storages S3, GCS, and Azure Blob backends automatically; on FileSystemStorage (the default) or any other backend it refuses with 501 Not Implemented rather than hand back a link labeled “signed” that never actually expires. Set this to true only if your configured backend genuinely signs its URLs. |
TRUEPPM_ALLOW_UNENCRYPTED_DB | false | Operator opt-in to run production against a DATABASE_URL that has no sslmode parameter (e.g. when TLS to the database is enforced at the network layer). prod refuses to boot on such a URL unless this is true; when set, the boot logs a warning instead. |
TRUEPPM_DJANGO_ADMIN_ENABLED | false (true under settings.dev) | Shipped in 0.4. Whether /admin/ answers at all. Django admin is a plain Django view, so none of the API’s login defenses reach it by inheritance — no login throttle, no auth.login_failed / auth.login_succeeded audit line, and no enforced-SSO check — while the admin bootstrap guarantees a superuser exists to guess against. Off by default, so every path under /admin/ answers 404 (not 403 — the path is not advertised). Set true only if you actually use the admin; the login is then throttled, audited, and seam-checked exactly like POST /api/v1/auth/token/, sharing its buckets rather than adding a second allowance. This is an application control, so it holds on Compose, bare-metal, and a kubectl port-forward alike — unlike the chart’s web.adminAccess deny, which only governs traffic through the web tier. See Reaching Django admin. |
TRUEPPM_DEMO_READ_ONLY | false | Turns the deployment into a read-only demo: every POST, PUT, PATCH and DELETE under /api/ is refused with 403 and code: "demo_read_only" (see the demo read-only 403), regardless of the caller’s role or the endpoint’s permission class. Only sign-in (POST /api/v1/auth/token/), token refresh and sign-out are allowed, so a visitor can log in but cannot change anything — including creating a project, posting a comment, uploading a file, or requesting a password-reset email. Leave unset on any deployment that people actually work in. Accepts true/false (also 1/0, yes/no, on/off); any other value refuses to boot rather than being read as false, so a typo cannot leave a demo writable. Applies to /api/ only: WebSocket traffic and /admin/ are closed separately at the edge and by TRUEPPM_DJANGO_ADMIN_ENABLED. |
TRUEPPM_DEMO_LOGIN_HINT | (empty) | The shared account a read-only demo publishes on its login screen, as username:password (split on the first colon, so the password may contain colons). Emitted on the public /api/v1/edition/ endpoint as demo_login_hint, and only while TRUEPPM_DEMO_READ_ONLY is true — leaving this set after turning the demo off does not keep broadcasting it. When it is set, the login screen’s primary action becomes an Explore the demo button that signs the visitor in to this shared account in one click (session-only, through the same /api/v1/auth/token/ endpoint as the form, then on to the account’s server-resolved landing page), with the credential printed beside it and a note explaining that everyone shares the account and nothing a visitor changes is saved. Because every visitor signs in as one username, the chart raises the per-username login_account throttle for this mode (demo.throttle.loginAccountRate); the per-IP login throttle is unchanged and bounds each visitor; the signed-in app then carries a persistent “Read-only demo” bar. Set this only on a demo deployment, and only to an account whose password is meant to be public — it is deliberately its own variable rather than something derived from a real password setting, so no live secret can be promoted to published copy by accident. A value that is not a non-empty username:password pair refuses to boot, rather than leaving a demo nobody can sign in to. |
TRUEPPM_DEMO_RESET_SCHEDULE | (empty) | The reset CronJob’s own five-field cron expression, restated to the api pod (#4152) so the app can state the cadence — “Sample data · resets daily at 08:00 UTC” on the demo bar — from the value that actually drives the reset, rather than a hardcoded number that drifts the moment an operator retunes it. Set by the Helm chart from demo.reset.schedule when demo.enabled and demo.reset.enabled are both true; empty otherwise. Emitted on /api/v1/edition/ as demo_reset_schedule, and only while TRUEPPM_DEMO_READ_ONLY is true. Not validated as a cron expression here — a malformed value degrades to the bar’s periodic-reset copy, never to a false safety claim. |
TRUEPPM_ALLOW_WILDCARD_HOSTS | false | Shipped in 0.4. Operator acknowledgment that ALLOWED_HOSTS is a bare *. prod refuses to boot on * unless this is true; when set, the boot logs a warning instead. * disables host validation entirely, which is the only bound on the absolute URLs TruePPM builds — see Host names you must include. Wildcard subdomains (.example.com) do not need this. |
CSRF_TRUSTED_ORIGINS | (empty) | Comma-separated origins (scheme included) Django trusts for CSRF validation, e.g. https://trueppm.example.com. A single-origin deploy needs no value. Set it when a proxy rewrites the Origin / Referer header so Django sees an origin it did not serve. It does not enable a split-origin deploy, which is unsupported. |
TRUEPPM_FRONTEND_BASE_URL | (empty) | Public origin the web app is served from, e.g. https://trueppm.example.com. Used to build absolute task deep-links in notification emails (e.g. task.blocked) and the page the OIDC callback sends the browser to once sign-in completes. Leave empty on a genuinely single-origin deploy — emails omit the link (still carrying the blocker type, age, and actor) and the OIDC callback falls back to a relative redirect that resolves correctly because the API and the SPA share an origin. Leaving it empty on a split-origin setup — including the bundled local development stack, where the SPA (Vite, :5173) and the API (:8000) are on different ports — sends the post-login redirect to whatever origin the browser is currently on instead of the SPA, which 404s. No trailing slash, no path. Must match ALLOWED_HOSTS and TRUEPPM_PUBLIC_API_BASE_URL — see One origin, four variables. The legacy bare FRONTEND_BASE_URL is still accepted as a fallback. |
TRUEPPM_AUTH_REFRESH_COOKIE_SECURE | true | Sets the Secure flag on the refresh-token cookie. The browser drops a Secure cookie over plain HTTP, so set false only on a non-HTTPS dev/preview host. The dev settings already default this to false for localhost. Legacy bare AUTH_REFRESH_COOKIE_SECURE still accepted. |
TRUEPPM_AUTH_REFRESH_COOKIE_SAMESITE | Strict | SameSite policy for the refresh cookie. Strict blocks the cookie on any cross-site request, which is correct for the supported single-origin deploy. Relax it to Lax or None only when TruePPM is framed by another site; None additionally requires Secure (HTTPS). Shipped in 0.4: the value is validated at boot, not passed through as-is. Only Strict, Lax, and None (case-insensitive) are recognized. Lax is accepted but logs a boot WARNING naming what it gives up (the cookie stops riding a cross-site POST, the vector these endpoints care about, but still rides a cross-site top-level GET navigation). None is refused — and silently forced back to Strict, logged at CRITICAL — unless TRUEPPM_AUTH_REFRESH_COOKIE_SAMESITE_NONE_ACK is also set (see below); an unrecognized value (a typo) is refused the same way. POST /api/v1/auth/token/refresh/ and POST /api/v1/auth/logout/ also independently reject a cross-site Sec-Fetch-Site or Origin (see When the settings above still matter) regardless of this setting — relaxing SameSite for framing does not disable that check. Legacy bare AUTH_REFRESH_COOKIE_SAMESITE still accepted. |
TRUEPPM_AUTH_REFRESH_COOKIE_SAMESITE_NONE_ACK | (empty) | Shipped in 0.4. Acknowledgment sentinel required in addition to TRUEPPM_AUTH_REFRESH_COOKIE_SAMESITE=None to actually drop SameSite protection on the refresh cookie: i-understand-this-removes-refresh-csrf-protection. Without it, None is refused and the cookie stays Strict, logged at CRITICAL — a stray override cannot silently remove the endpoints’ only unconditional CSRF control. Same acknowledgment shape as TRUEPPM_RATE_LIMIT_DISABLE_ACK above. |
TRUEPPM_AUTH_REFRESH_COOKIE_NAME | trueppm_refresh | Name of the refresh-token cookie. Override only to avoid a collision with another app on the same domain. Legacy bare AUTH_REFRESH_COOKIE_NAME still accepted. |
TRUEPPM_AUTH_REFRESH_COOKIE_PATH | /api/v1/auth/ | Path the refresh cookie is scoped to. Must cover both /api/v1/auth/token/refresh/ and /api/v1/auth/logout/ — a narrower value means the browser never sends the cookie to logout, so logging out clears it locally without revoking the token server-side. Override only if you reverse-proxy the API under a non-default base path. Legacy bare AUTH_REFRESH_COOKIE_PATH still accepted. |
CSP_CONNECT_SRC | 'self' wss: | Space-separated connect-src sources for the Content-Security-Policy header — the origins the browser may open XHR / fetch / WebSocket connections to. The default covers the SPA’s own API and WebSocket calls on the single origin TruePPM requires. Add an origin here only for a genuinely external destination, such as an analytics endpoint or an object store you serve attachment downloads from directly. |
TRUEPPM_WEBHOOK_RETENTION_DAYS | 7 | Days of webhook delivery records to keep before the nightly purge. See Retention. |
TRUEPPM_EXPORT_RETENTION_DAYS | 7 | Days of generated export artifacts to keep before purge. See Retention. |
TRUEPPM_SYNC_BATCH_RETENTION_HOURS | 24 | Hours of processed offline-sync upload batches to keep before purge. See Retention. |
TRUEPPM_SYNC_MAX_CONCURRENT_BATCHES | 4 | Maximum offline-sync upload batches one user may have applying at the same time. Each accepted batch is a heavy transaction (row locks + a schedule recompute), so this bounds simultaneous heavy writes per user as defense-in-depth over the per-minute upload rate limit. A user exceeding the cap gets an HTTP 429 with a short Retry-After and retries once in-flight work drains. If the throttle store (Valkey/Redis) is unreachable the guard degrades to off — the rate limit remains the hard bound. |
TRUEPPM_SYNC_INFLIGHT_TTL_SECONDS | 120 | Time-to-live (seconds) on the per-user in-flight sync-batch counter that backs TRUEPPM_SYNC_MAX_CONCURRENT_BATCHES. Guards against a slot leaking if a worker dies mid-apply: the counter is reclaimed once no further batch refreshes it within this window. Set comfortably above the longest legitimate batch-apply time so an in-progress upload is never counted out from under a live request. |
TRUEPPM_SYNC_BATCH_MAX_ROWS | 500 | Maximum rows (created + updated + deleted combined) a single mobile sync upload batch may contain. The batch applies in one transaction, so this bounds how long that transaction — and its per-task row locks — can be held by a single request. A client with more pending rows splits them across multiple upload batches. |
TRUEPPM_SYNC_BATCH_MAX_OWNERS | 500 | Shipped in 0.4: now registered via env.int(), like its TRUEPPM_SYNC_BATCH_MAX_ROWS neighbor above, so setting the container environment variable takes effect (previously it was read only via getattr(settings, …) with nothing wiring the env var to it — #3735). Maximum inline owners entries summed across every created/updated row in a single mobile sync upload batch. A per-row cap of 100 already applies to each row’s own owners list; this bounds the batch as a whole, so TRUEPPM_SYNC_BATCH_MAX_ROWS rows each naming owners cannot multiply into tens of thousands of entries in one request. Checked before the write transaction opens, alongside the row-count cap above. |
TRUEPPM_SYNC_PULL_PAGE_SIZE | 1000 | Default page size for the offline delta pull (since= cursor). Bounds a cold-start sync (since=0) to this many rows per response instead of materializing an entire project into one unbounded multi-MB payload; the client loops on the returned cursor for the rest. |
TRUEPPM_SYNC_PULL_MAX_PAGE_SIZE | 5000 | Hard ceiling on a client-requested page_size for the sync pull. Clamps a caller-supplied page size so a single request can never re-open the unbounded-response cliff TRUEPPM_SYNC_PULL_PAGE_SIZE exists to close. |
RETENTION_PURGE_INFLIGHT_SECONDS | 600 | Lock TTL (seconds) guarding against overlapping retention-purge runs. See Retention. |
TRUEPPM_BEAT_STALE_SECONDS | 120 | Age (seconds) after which the last Celery-beat heartbeat is considered stale by /health/beat/. See Beat Liveness. |
TRUEPPM_CONFIG_NOTICE_COOLDOWN_SECONDS | 600 | Shipped in 0.4. Window (seconds) in which the same person repeating the change whose config-change notice was the last one sent for a project — hiding a column, showing it, and hiding it again — is not re-notified. A different change, the same change by someone else, or a change back after a different notice was sent in between always sends. 0 disables the cooldown. If Valkey is unreachable the notice is sent. |
TRUEPPM_READYZ_ALLOW_DB_AHEAD | false | Shipped in 0.4. Lets /api/v1/readyz report ready on a pod that booted against a database recording migrations it does not ship (migration_state: ahead) — the state a deliberate image-only rollback produces. Only that boot-time case is gated; a pod that drifts to ahead mid-rolling-upgrade stays ready regardless of this setting. Off by default because the probe cannot tell an additive rollback from a destructive one; set it to true only after classifying the release’s migrations as additive, and return it to false once you have rolled forward. It never suppresses the behind gate, the drift is still reported in migration_state, and each pod logs a WARNING once when the override first takes effect. See Rollback. |
TRUEPPM_EXTERNAL_SYNC_ON_OPEN_STALE_SECONDS | 300 | Age (seconds) past which a connected personal external source (e.g. Jira, see Connected Accounts) is considered stale enough that opening My Work enqueues a background refresh pull. Raise this on a rate-sensitive token; the pull never blocks the My Work response either way. |
TRUEPPM_INTEGRATION_ALLOWED_HOSTS | (empty) | Comma-separated allow-list of self-hosted integration hosts a user’s Connected Account credential may be sent to — GitHub Enterprise / GitLab CE for task links, and self-hosted Jira Data Center / Server for personal read-only sync. Atlassian Cloud (*.atlassian.net) and github.com / gitlab.com are always allowed and need no entry. This is a security gate: a personal access token is only ever put on the wire toward a host on this list, so a user cannot be tricked into exfiltrating their PAT to an arbitrary host (host is matched by name, e.g. jira.example.com). Bound as the INTEGRATION_ALLOWED_HOSTS Django setting. This list governs only where a token may be sent — it does not by itself make a private host reachable. The outbound-request SSRF guard independently blocks any host resolving to a private/internal address, so an internal-only Data Center / GitLab CE instance must also be named in TRUEPPM_EGRESS_ALLOWLISTED_HOSTS (see the row above). A host reachable over a public IP needs nothing beyond this setting. Both variables are exact hostname matches, so the same host string goes in each. |
TRUEPPM_RECURRENCE_HORIZON_DAYS | 14 | Look-ahead window (days) for spawning recurring-task occurrences. See Recurring tasks. |
TIMETRACKING_TIMER_MAX_MINUTES | 600 | Stale-timer ceiling (minutes) for the running work timer that feeds the Timesheet. A running timer past this many minutes is flagged stale on GET /me/timer/, and stop caps the logged duration at the ceiling rather than the raw elapsed time — so a timer left running over a weekend logs the ceiling, not thousands of minutes. |
TIMETRACKING_BACKDATE_DAYS | 60 | Manual time-entry backdate window (days). A manual entry_date is rejected if it is in the future or older than this many days, so a contributor can fill in last week’s hours but not rewrite arbitrary history. |
SIGNAL_CEILING_PROPOSAL_TTL_HOURS | 72 | How long a team-ratification proposal to raise a signal’s visibility ceiling stays open before it lazily expires unratified (the ceiling is left unchanged — silence is never consent for widening a team signal’s exposure). Evaluated lazily on read/vote/propose; no Beat sweep is required. |
TRUEPPM_TELEMETRY_TEST_EXPORT_TIMEOUT_SECONDS | 5 | Wall-clock bound (seconds) for the admin telemetry test-export probe: caps the one-off canary export and reachability check to the configured OTLP collector so a dead collector can never hang the request thread. |
TRUEPPM_WORKFLOW_BACKEND | trueppm_api.workflows.backends.default.DefaultWorkflowBackend | Dotted path to the WorkflowBackend implementation for the durable-execution engine. The OSS default composes the transactional outbox with Celery. Enterprise editions register an alternate backend (e.g. Temporal) by overriding this; do not change it in an OSS deployment. The legacy bare WORKFLOW_BACKEND is still read as a fallback when the prefixed var is unset. |
TRUEPPM_WORKFLOW_HISTORY_RETENTION_DAYS | 30 | Days of WorkflowHistoryEvent records to keep before the nightly workflows.purge_old_records task purges them. Set the Django setting to None (or 0) to disable history purging. The legacy bare WORKFLOW_HISTORY_RETENTION_DAYS is still read as a fallback when the prefixed var is unset. |
TRUEPPM_WORKFLOW_DRAIN_BATCH_SIZE | 200 | Maximum rows the workflow outbox/timer drains process per tick. Bounds the work per run so a large backlog (e.g. after a broker outage) cannot exceed the Celery task time limit — later ticks drain the remainder. The legacy bare WORKFLOW_DRAIN_BATCH_SIZE is still read as a fallback when the prefixed var is unset. |
TRUEPPM_WORKFLOW_PURGE_BATCH_SIZE | 500 | Rows deleted per statement by the nightly workflow retention purge. The purge deletes in bounded chunks rather than one unbounded statement, so the first run on a mature install cannot hold a long lock over a large slice of the history/outbox tables. The legacy bare WORKFLOW_PURGE_BATCH_SIZE is still read as a fallback when the prefixed var is unset. |
TRUEPPM_IDEMPOTENCY_RETENTION_HOURS | 24 | Hours to retain stored Idempotency-Key responses, purged hourly by the Celery beat task. After expiry, a retry with the same key re-runs the mutation. Set the Django setting to None to disable automatic purging. The legacy bare IDEMPOTENCY_RETENTION_HOURS is still read as a fallback when the prefixed var is unset. |
TRUEPPM_IDEMPOTENCY_MAX_BODY_BYTES | 1048576 | Maximum stored response body size, in bytes (1 MiB default). Responses larger than this are not stored — the claim row is dropped so a retry re-runs the mutation. Single-object mutation responses effectively never approach this limit. The legacy bare IDEMPOTENCY_MAX_BODY_BYTES is still read as a fallback when the prefixed var is unset. |
EMAIL_BACKEND | django.core.mail.backends.smtp.EmailBackend | Django mail backend. Change it to django.core.mail.backends.console.EmailBackend to print mail to the log instead of sending it — useful when validating templates without a relay. |
EMAIL_HOST | (empty) | SMTP relay hostname. Deliberately empty rather than Django’s implicit localhost, so an unconfigured deploy fails visibly instead of silently trying to talk to a mail server on the container itself. |
EMAIL_PORT | 587 | Not Django’s 25. 587 is submission-with-STARTTLS, which pairs with the EMAIL_USE_TLS default below. Use 465 with EMAIL_USE_SSL=true for implicit TLS. |
EMAIL_HOST_USER / EMAIL_HOST_PASSWORD | (empty) | Relay credentials. Supply the password through a Secret, never a values file. |
EMAIL_USE_TLS | true | Not Django’s False. STARTTLS on the submission port. Mutually exclusive with EMAIL_USE_SSL. |
EMAIL_USE_SSL | false | Implicit TLS (port 465). Set exactly one of this and EMAIL_USE_TLS. |
EMAIL_TIMEOUT | 10 | Seconds. Not Django’s None — an unbounded socket timeout means one unreachable relay can hold a Celery worker indefinitely. |
DEFAULT_FROM_EMAIL | notifications@trueppm.local | Set this. .local is a reserved TLD that most relays reject outright, so leaving the default silently breaks outbound mail on an otherwise correct SMTP configuration. |
Every one of the EMAIL_* variables above binds directly from the container
environment — set them as plain env vars or Helm env: values, no settings
override needed. See
Outbound email for how they relate to the in-app
Email & SMTP page.
Each row above was previously a single line whose Default column read
(Django default) for all of them. Four are overridden, and the
DEFAULT_FROM_EMAIL one breaks mail delivery when left alone.
In-product feedback (“Report a bug”)
Section titled “In-product feedback (“Report a bug”)”TruePPM shows a Report a bug entry in the account menu and the command palette. Choosing it opens a dialog containing the exact text that would be submitted; the user can edit it, then continue to your tracker in a new tab.
It is a link, not a beacon. TruePPM sends nothing on this path. Rendering the
control makes no network request, opening the dialog makes no network request,
and there is no background telemetry attached to it. The context travels only as
query parameters in a URL the user can read before submitting, and the link
carries rel="noopener noreferrer" so the tracker is not even handed the page
URL the user came from.
What is included — the minimum that makes a report actionable:
- the TruePPM version, edition, and build SHA;
- the screen the user was on, as a route shape (
/projects/:id/board); - the browser’s user-agent string;
- the number of tasks in the project the user was viewing, when that count is already sitting in the page’s own data — the dialog never fetches it, so a page that has not loaded a task list yet simply omits the line rather than reporting zero.
What is not included — and cannot be, by construction:
- any workspace, program, project, task, or user identifier;
- the user’s name or email address;
- any query string, search term, or filter value;
- any schedule, comment, or attachment content.
Identifiers are stripped from the route before the body is assembled: the query
string and fragment are dropped entirely, and any path segment that looks like an
identifier is replaced with :id.
Two settings, under Settings → System → Feedback (workspace admin only):
| Setting | Default | Effect |
|---|---|---|
| Report a bug control | Shown | Hides the entry entirely — from the account menu and the command palette — when set to Hidden. A locked-down install does not advertise a route it has closed. |
| Tracker URL | (blank) | Where the control points. Blank means the public TruePPM issue tracker. Set it to your own tracker or helpdesk to keep reports internal. |
The default tracker URL is not stored in the database. A blank value means “use the built-in default”, so an operator who never touched the setting follows the default wherever it moves in a future release, rather than being pinned to whatever it was at install time.
The prefilled title and description are passed as issue[title] and
issue[description] query parameters (GitLab’s new-issue form). A tracker that
does not understand them ignores them and simply opens its own blank form, so
repointing at an arbitrary helpdesk URL still works.