Skip to content

Configuration

All configuration is via environment variables. For local development, docker-compose.yml sets sensible defaults.

VariableDescriptionExample
SECRET_KEYDjango secret key. Use a long random string.$(python -c "import secrets; print(secrets.token_urlsafe(50))")
DATABASE_URLPostgreSQL connection string. Include ?sslmode=require — production refuses to boot on a URL without an sslmode parameter (the connection could otherwise fall back to unencrypted transport), unless you set TRUEPPM_ALLOW_UNENCRYPTED_DB=true because TLS is enforced at the network layer.postgres://trueppm:password@db:5432/trueppm?sslmode=require
REDIS_URLConnection string for the Celery broker / Channels layer / cache. TruePPM ships with Valkey (BSD-licensed Redis fork, wire-compatible); the redis:// scheme works against either. Do not append a database index — TruePPM addresses four (03) and adds them itself. Ignored when TRUEPPM_VALKEY_SENTINELS is set.redis://valkey:6379
TRUEPPM_VALKEY_SENTINELSExperimental (0.4). Comma-separated host:port list of Valkey Sentinel nodes. Setting it to a non-empty value switches every consumer to Sentinel mode, replacing REDIS_URL. Not yet verified against a live quorum failover — validate in staging first. Use three or more so a quorum can authorize failover. See Valkey HA.s0:26379,s1:26379,s2:26379
TRUEPPM_VALKEY_MASTER_NAMEThe name the Sentinels monitor the primary under. Required whenever TRUEPPM_VALKEY_SENTINELS is set — the app refuses to boot without it.mymaster
TRUEPPM_VALKEY_PASSWORDPassword for the Valkey data nodes in Sentinel mode.s3cret
TRUEPPM_VALKEY_SENTINEL_PASSWORDPassword for the Sentinel nodes themselves, when it differs from the data-node password.s3ntinel
TRUEPPM_VALKEY_USE_TLSUse TLS to the Valkey data nodes in Sentinel mode.false
DJANGO_SETTINGS_MODULESettings module to load.trueppm_api.settings.prod
ALLOWED_HOSTSComma-separated list of allowed hostnames.trueppm.example.com
INTEGRATION_ENCRYPTION_KEYFernet key that encrypts stored integration credentials (connected-account PATs) at rest. Production refuses to boot if this is empty — the guard runs at settings-import time, so a missing key crash-loops the deploy rather than failing later.$(python3 -c "from cryptography.fernet import Fernet; print(Fernet.generate_key().decode())")
Attachment storagePick one: set TRUEPPM_DEFAULT_FILE_STORAGE to a persistent object-storage backend and TRUEPPM_S3_BUCKET_NAME to its bucket, or set TRUEPPM_ALLOW_LOCAL_ATTACHMENT_STORAGE=true if local disk is backed by a persistent volume. Production refuses to boot on the ephemeral local default otherwise (see object storage for the full variable set).storages.backends.s3.S3Storage
VariableDefault
SECRET_KEYdev-secret-key-change-in-prod
DATABASE_URLpostgres://trueppm:trueppm@db:5432/trueppm
REDIS_URLredis://valkey:6379
DJANGO_SETTINGS_MODULEtrueppm_api.settings.dev
ALLOWED_HOSTS*
VariableDefaultDescription
JWT_SIGNING_KEYSECRET_KEYDedicated 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_DAYS30Session 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_HOURS12Sliding 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_EDITIONcommunityEdition 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_VERSIONthe installed package versionVersion string surfaced on /api/v1/edition/; will also feed the in-product feedback payload once that control ships 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_REDIRECTfalseProduction-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), used only to build the OIDC redirect_uri your identity provider redirects back to: {TRUEPPM_PUBLIC_API_BASE_URL}/api/v1/auth/oidc/callback/. Empty (the default) falls back to the incoming request’s absolute URL — correct for a single-origin dev setup, but behind a reverse proxy or load balancer set this explicitly so the value your IdP’s allow-listed redirect URI must match is deterministic rather than dependent on request headers. A trailing slash is stripped. See Single sign-on.
TRUEPPM_HISTORY_RETENTION_DAYS90How 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_DAYS30How 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_MB50Per-file size cap for MS Project (.mpp / .xml) imports, in megabytes. See MS Project import limit below.
JIRA_IMPORT_MAX_UPLOAD_MB25Per-file size cap for Jira XML imports, in megabytes. See Jira import limit below.
CSV_IMPORT_MAX_UPLOAD_MB10Per-file size cap for CSV / Excel imports, in megabytes. See CSV / Excel import limits below.
CSV_IMPORT_MAX_ROWS5000Maximum 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_MB100Decompression-bomb ceiling for .xlsx uploads: the maximum total uncompressed size the workbook may declare.
TRUEPPM_THROTTLE_ANON_RATE60/minGeneral 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_RATE1000/minGeneral 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_NUM_PROXIES1Number of trusted reverse proxies in front of the API. Used to extract the real client IP for the unauthenticated rate limit from the X-Forwarded-For chain. The standard Helm chart runs a single ingress (1); set to your actual proxy depth, or 0 if the API is reached directly (uses REMOTE_ADDR). An incorrect value lets a client spoof its IP and evade the anon limit, so match it to your deployment.
TRUEPPM_RATE_LIMIT_ENABLEDtrueGlobal 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_RATE5/minPer-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_ENABLEDtrueOrg-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_THROTTLE_SHARE_ACCESS_RATE60/minRate 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_RATE20/minRate limit for an Admin minting public board links, per account, in DRF <count>/<period> form.
TRUEPPM_THROTTLE_SAMPLE_LOAD_RATE6/minRate 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_RATE6/minRate 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_RATE20/minRate 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_RATE60/minRate 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_SAMPLE_DOWNLOAD_RATE60/minRate 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_RATE6/minRate 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_RATE120/minBaseline rate limit for API-token reads on the MCP read surface, per token, in DRF <count>/<period> form. Does not affect human session/JWT traffic. See MCP read-surface rate limiting below.
TRUEPPM_THROTTLE_MCP_READ_COMPUTE_RATE12/minTighter rate limit stacked on the four compute-heavy MCP tools (what-if, latest Monte Carlo, forecast, sprint-forecast), per token, in DRF <count>/<period> form. See MCP read-surface rate limiting below.
TRUEPPM_MCP_ENABLEDtrueInstance-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 on the same endpoints is unaffected. The operator lever for “no agent access on this instance, period.” See MCP read-surface rate limiting below.
TRUEPPM_MAX_PERSONAL_ACCESS_TOKENS10Maximum 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_PINS100Maximum 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_MINUTE5Per-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_TASK_SYNC_STEADY_STATE_LIMIT100Per-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_LIMIT1000Per-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_CARDS1000Maximum 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_TASKS1000Maximum 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_STORAGEdjango.core.files.storage.FileSystemStorageBackend 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_STORAGEfalseOperator opt-in to run production on the local FileSystemStorage default (e.g. 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.
STATIC_ROOTstaticfiles/ under the app directoryFilesystem 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_URLSfalseOperator 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_DBfalseOperator 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.
CSRF_TRUSTED_ORIGINS(empty)Comma-separated origins (scheme included) trusted for cross-origin POST/CSRF. Required only for split-origin deploys where the web app and API are served from different hostnames, e.g. https://app.example.com,https://api.example.com.
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). Leave empty to omit the link — emails still carry the blocker type, age, and actor. No trailing slash, no path. The legacy bare FRONTEND_BASE_URL is still accepted as a fallback.
TRUEPPM_AUTH_REFRESH_COOKIE_SECUREtrueSets 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_SAMESITEStrictSameSite policy for the refresh cookie. Strict blocks the cookie on any cross-site request. Split-origin deploys must relax this to Lax or None so the refresh request carries the cookie; None additionally requires Secure (HTTPS). See split-origin notes. Legacy bare AUTH_REFRESH_COOKIE_SAMESITE still accepted.
TRUEPPM_AUTH_REFRESH_COOKIE_NAMEtrueppm_refreshName 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/token/refresh/Path the refresh cookie is scoped to. 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. Split-origin deploys must add the API origin (and its wss:// origin) here, e.g. 'self' https://api.example.com wss://api.example.com. See split-origin notes.
TRUEPPM_WEBHOOK_RETENTION_DAYS7Days of webhook delivery records to keep before the nightly purge. See Retention.
TRUEPPM_EXPORT_RETENTION_DAYS7Days of generated export artifacts to keep before purge. See Retention.
TRUEPPM_SYNC_BATCH_RETENTION_HOURS24Hours of processed offline-sync upload batches to keep before purge. See Retention.
TRUEPPM_SYNC_MAX_CONCURRENT_BATCHES4Maximum 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_SECONDS120Time-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_ROWS500Maximum 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_PULL_PAGE_SIZE1000Default 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_SIZE5000Hard 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_SECONDS600Lock TTL (seconds) guarding against overlapping retention-purge runs. See Retention.
TRUEPPM_BEAT_STALE_SECONDS120Age (seconds) after which the last Celery-beat heartbeat is considered stale by /health/beat/. See Durability.
TRUEPPM_READYZ_ALLOW_DB_AHEADfalseShips 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_SECONDS300Age (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). The host must also be reachable from the TruePPM server over a public (non-private) IP — the outbound-request SSRF guard independently blocks any host resolving to a private/internal address, so an internal-only Data Center instance is not yet supported.
TRUEPPM_RECURRENCE_HORIZON_DAYS14Look-ahead window (days) for spawning recurring-task occurrences. See Recurring tasks.
TIMETRACKING_TIMER_MAX_MINUTES600Stale-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_DAYS60Manual 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_HOURS72How 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_SECONDS5Wall-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_BACKENDtrueppm_api.workflows.backends.default.DefaultWorkflowBackendDotted 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_DAYS30Days 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_SIZE200Maximum 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_SIZE500Rows 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_HOURS24Hours 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_BYTES1048576Maximum 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_HOST / EMAIL_PORT / EMAIL_HOST_USER / EMAIL_TIMEOUT / …(Django default)SMTP settings for notification and invite email. Every one of these binds directly from the container environment — set them as plain env vars or Helm env: values, no settings override needed. See Outbound email for the full variable list and how they relate to the in-app Email & SMTP page.

Task attachments are the only user data TruePPM writes outside PostgreSQL. The local FileSystemStorage default is ephemeral in a container, and production refuses to boot on it, so a durable deploy points TRUEPPM_DEFAULT_FILE_STORAGE at an S3-compatible bucket.

The API image bundles the S3 backend, so the two variables below are all a deploy against AWS S3 needs:

Terminal window
TRUEPPM_DEFAULT_FILE_STORAGE=storages.backends.s3.S3Storage
TRUEPPM_S3_BUCKET_NAME=trueppm-attachments

Credentials are deliberately not required. Left unset, the AWS SDK resolves them from its own chain — IRSA on EKS, an IAM instance profile, or ~/.aws — which is preferable to pinning static keys into a Secret. Set TRUEPPM_S3_ACCESS_KEY_ID / TRUEPPM_S3_SECRET_ACCESS_KEY only when no such role is available (MinIO, Ceph, Wasabi).

VariableDefaultDescription
TRUEPPM_S3_BUCKET_NAME(empty)Bucket that holds task attachments. Required when TRUEPPM_DEFAULT_FILE_STORAGE names an S3 backend — startup fails with trueppm.E008 if it is missing, rather than accepting the config and failing on the first upload.
TRUEPPM_S3_ENDPOINT_URL(empty)Endpoint for a non-AWS S3-compatible store, e.g. http://minio:9000. Leave empty for AWS S3 so the SDK resolves the real regional endpoint.
TRUEPPM_S3_REGION_NAMEus-east-1Region for the bucket. Must be non-empty even against MinIO: SigV4 embeds the region in the credential scope, so an empty value produces an unusable signature.
TRUEPPM_S3_ADDRESSING_STYLE(SDK default auto)Set to path for MinIO and Ceph RGW, which do not serve virtual-hosted bucket URLs without per-bucket DNS. Leave unset for AWS S3.
TRUEPPM_S3_ACCESS_KEY_ID(empty)Static access key. Omit to use the SDK credential chain (IRSA / instance profile).
TRUEPPM_S3_SECRET_ACCESS_KEY(empty)Static secret key. Omit to use the SDK credential chain.
TRUEPPM_S3_SIGNATURE_VERSIONs3v4Signing algorithm for presigned URLs. Leave at the default — the SDK would otherwise fall back to the deprecated SigV2 whenever an endpoint URL is set, and AWS rejects SigV2 in every region created after 2014.
TRUEPPM_S3_QUERYSTRING_EXPIRE900Lifetime, in seconds, of the presigned URL returned by the attachment Get signed download URL action.
Terminal window
TRUEPPM_DEFAULT_FILE_STORAGE=storages.backends.s3.S3Storage
TRUEPPM_S3_BUCKET_NAME=trueppm-attachments
TRUEPPM_S3_ENDPOINT_URL=http://minio:9000
TRUEPPM_S3_ADDRESSING_STYLE=path
TRUEPPM_S3_ACCESS_KEY_ID=your-access-key
TRUEPPM_S3_SECRET_ACCESS_KEY=your-secret-key

Create the bucket before first use — TruePPM does not create it for you. Keep it private: attachments are reached only through a presigned URL, and a world-readable bucket makes that signature meaningless.

The image bundles S3 only. storages.backends.gcloud.GoogleCloudStorage and storages.backends.azure_storage.AzureStorage are recognized as signing-capable by the signed-URL action, but their client libraries are not installed — naming one fails startup with trueppm.E007 and the exact package to install:

(trueppm.E007) STORAGES['default']['BACKEND'] is set to
'storages.backends.gcloud.GoogleCloudStorage', which cannot be imported:
Could not load Google Cloud Storage bindings.
HINT: Install it with: pip install 'django-storages[google]' (this image
bundles django-storages[s3] only), or point TRUEPPM_DEFAULT_FILE_STORAGE at
a backend the image carries.

To run on GCS or Azure Blob, install the extra into a derived image:

FROM registry.gitlab.com/trueppm/trueppm/api:latest
USER root
RUN pip install --no-cache-dir 'django-storages[google]'
USER trueppm

The attachment Get signed download URL action returns a time-limited URL only when it can confirm the backend genuinely signs one. On the S3 backend above it does, and the URL carries a real X-Amz-Expires. On FileSystemStorage — or any backend the action does not recognize — it refuses with 501 Not Implemented rather than hand back a permanent link labeled “signed”. If you run a signing-capable backend that is not recognized, opt in with TRUEPPM_ATTACHMENT_STORAGE_SIGNS_URLS=true.

The caller may request a lifetime with ?ttl=<seconds> (default 900, hard-capped at 3600). That value is applied to the signature itself, so the expires_at in the response and the URL’s real expiry always agree. TRUEPPM_S3_QUERYSTRING_EXPIRE sets the default used when a request omits ttl.

TruePPM applies a general default rate limit to every API endpoint that does not declare its own throttle, so a self-hosted instance has baseline protection against runaway clients and resource-starvation abuse. Two tunable rates control it:

VariableDefaultApplies to
TRUEPPM_THROTTLE_ANON_RATE60/minUnauthenticated requests, bucketed per client IP
TRUEPPM_THROTTLE_USER_RATE1000/minAuthenticated requests, bucketed per account

Both use Django REST Framework’s <count>/<period> syntax, where period is sec, min, hour, or day (for example 120/min or 5000/hour).

A few behaviors are worth knowing:

  • The Kubernetes probe endpoints are exempt. /api/v1/health/ (liveness / readiness) and /api/v1/edition/ (the shell’s startup edition read) are never counted, so an orchestrator’s tight probe loop cannot exhaust the shared limit and trigger spurious pod restarts.
  • Scoped endpoints replace, not stack. Endpoints with their own stricter limit — login, token refresh, Monte Carlo, and the other scoped throttles — keep only that specific limit; the general default does not add to it.
  • Exceeding a limit returns 429 Too Many Requests with a standard Retry-After header telling the client how many seconds to wait.

The throttle count lives in the configured cache (Valkey/Redis in production), so the limit is shared across all API replicas rather than per-process.

Rate limits tune how fast callers may go; the TRUEPPM_RATE_LIMIT_ENABLED switch decides whether any throttle applies at all. It exists so a load test — or a throwaway benchmarking instance — can measure raw throughput without the per-account limit tripping. It is not for production use.

Turning it off deliberately takes two environment variables, in every environment:

Terminal window
TRUEPPM_RATE_LIMIT_ENABLED=false
TRUEPPM_RATE_LIMIT_DISABLE_ACK=i-understand-this-disables-abuse-protection
  • Setting TRUEPPM_RATE_LIMIT_ENABLED=false alone does nothing. Without the exact acknowledgment string the request is ignored, rate limiting stays on, and the server logs a CRITICAL line at startup. A stray flag can never silently remove protection — and it never crashes the app either; it fails toward the protected state.
  • When both are set, every throttle is bypassed: the general anon/user limits, every scoped endpoint limit, and even the fail-closed offline-sync write-path limit. “Off” means off, with no hidden exceptions.
  • It is deploy-time operator config only — there is no in-app toggle, so an authenticated user can never switch off this protection through the API. While it is off, workspace admins see a persistent red banner and a card under Settings → System, and the trueppm.ratelimit.enabled OpenTelemetry gauge reports 0, so the state is impossible to miss and easy to alert on.

Never set these on a production-facing deployment. Leave TRUEPPM_RATE_LIMIT_ENABLED at its default true to keep abuse protection on.

The login endpoint (POST /api/v1/auth/token/) is protected by two stacked throttles — both must pass before credentials are checked, so an attacker is bounded on two independent axes:

VariableDefaultBuckets byWhat it bounds
(fixed) login scope10/minClient IPPassword guessing from a single source address
TRUEPPM_THROTTLE_LOGIN_ACCOUNT_RATE5/minAccount (hashed username)Guessing against a single account across all source IPs

The per-account throttle exists because the IP throttle alone only caps guesses per source address. A distributed credential-stuffing attack that rotates through a botnet or proxy pool gets the full per-IP allowance from every fresh IP, so the aggregate guess rate against one account is unbounded. The account-keyed throttle closes that gap: once the per-account rate is exceeded, further login attempts for that username return 429 Too Many Requests no matter how many IPs participate.

  • The username is hashed before it becomes a cache key — the raw email or username is never written to the cache backend.
  • Both success and failure count toward the per-account bucket, so a normal interactive user (well under 5 logins/minute) is never affected while an automated attack is stopped quickly. Tighten or loosen the rate with TRUEPPM_THROTTLE_LOGIN_ACCOUNT_RATE using the same <count>/<period> syntax as the general rate limits.

Every rejected login also emits a structured auth.login_failed audit event on the trueppm.auth logger, carrying the hashed username and client IP (never the raw credential). Ship this logger to your SIEM to alarm on credential-stuffing bursts — a spike of failures against one username_hash from many client_ip values is the distributed-attack signature.

The MCP server exposes a read-only view of the OSS API to mcp:read API tokens so an AI agent can query schedules, forecasts, and Monte Carlo results. Those token reads are rate-limited per token so an agent retry loop — or a leaked read-only token — cannot burn arbitrary server compute. Two throttles apply:

VariableDefaultBuckets byApplies to
TRUEPPM_THROTTLE_MCP_READ_RATE120/minAPI tokenEvery MCP-readable endpoint
TRUEPPM_THROTTLE_MCP_READ_COMPUTE_RATE12/minAPI tokenThe four compute-heavy tools only

The four compute-heavy tools — what-if, latest Monte Carlo, forecast, and sprint-forecast — each run a CPM + Monte Carlo recompute per call, so they stack the tighter mcp_read_compute bucket on top of the baseline mcp_read one. A token calling one of them is bounded by whichever limit it hits first.

A few behaviors are worth knowing:

  • Only token traffic is throttled. Requests authenticated by a human session or JWT pass through untouched — they remain governed by the general TRUEPPM_THROTTLE_USER_RATE limit above — so tightening these rates never slows an interactive user browsing the same views.
  • Each token has its own bucket, keyed on the token’s id. Revoking and re-minting a token starts a fresh window, and two agents holding two tokens neither share nor starve one budget.
  • Exceeding a limit returns 429 Too Many Requests with a Retry-After header, the same as the other scoped throttles.

Both use Django REST Framework’s <count>/<period> syntax. Widen them for a trusted internal agent fleet, or tighten them under load.

Rate limits bound how fast agents read; the TRUEPPM_MCP_ENABLED kill switch decides whether they can read at all. Set it to false to deny every MCP token read across the whole instance:

  • Every request authenticated by an mcp:read token is refused with 403, even though the token still exists and carries the scope — the check is fail-closed and lives at the single guard chokepoint every MCP-readable endpoint shares.
  • Human session/JWT traffic on the same endpoints is unaffected. The switch gates only the API-token (agent) path, so interactive users and the web client keep working normally.
  • Each denied read is still recorded in the agent-action audit log as a policy refusal, so you retain a trail of what was attempted while access was off.

The default is true for backward compatibility. Flip it to false when you want “no agent access on this instance, period” — for a locked-down or regulated deployment, or as an incident-response lever — then back to true to restore it.

MS Project import accepts .mpp and .xml files. The upload is bounded on three axes, because file size alone does not bound either the derived object count or the work converting a .mpp creates:

VariableDefaultUnitWhat it bounds
MSPROJECT_MAX_UPLOAD_MB50MBMaximum size of a single MS Project import upload
MSPROJECT_MAX_ROWS20000tasksMaximum tasks (also applied to resources and dependency links) a single import may contain
MPXJ_MAX_OUTPUT_MB512MBCeiling on the XML MPXJ streams to stdout while converting a .mpp to MSPDI XML
MPXJ_MAX_HEAP_MB512MBJVM max-heap (-Xmx) for the MPXJ conversion subprocess

This cap was raised from a previously hardcoded 10 MB. An import is read fully into memory and stored base64-encoded in a single database row (about +33%), so a 50 MB upload already costs roughly 67 MB of memory and row size — keep the limit close to the practical MS Project file ceiling rather than maximizing it.

The byte cap alone does not bound the object graph a request can materialize. A 50 MB MSPDI XML file can encode roughly a million tasks, and the importer builds one Task object per row, computes the WBS over all of them, then bulk-creates the lot — a worker-memory / transaction-time denial-of-service that the byte ceiling alone does not stop. MSPROJECT_MAX_ROWS rejects the import outright once the task count (or resource or dependency-link count) exceeds it, rather than partially processing an oversized file. 20,000 tasks is far above any realistic hand-authored schedule.

Converting a .mpp is a bigger risk than parsing MSPDI XML. MS Project’s binary .mpp format is converted to XML by an external MPXJ (Java) subprocess. A decompression-bomb .mpp, still comfortably inside the 50 MB upload cap, can expand to multi-gigabyte XML — buffering that unbounded would OOM the worker. MPXJ_MAX_OUTPUT_MB streams the subprocess’s stdout and aborts past this many bytes; MPXJ_MAX_HEAP_MB is a second line of defense that bounds the JVM’s own heap, so even a bomb the byte ceiling hasn’t yet caught dies with a JVM OutOfMemoryError (a clean non-zero exit) rather than driving the host into swap.

The MS Project and Jira import paths (below) share one more bulk-write knob:

VariableDefaultUnitWhat it bounds
IMPORT_BULK_BATCH_SIZE500rowsbatch_size for the shared importer’s bulk_create() calls

Without a batch_size, Django would emit one giant multi-row INSERT that pins the whole task/dependency set in a single statement (and can exceed PostgreSQL’s parameter limit); chunking bounds the per-statement memory and parameter count.

Terminal window
# Allow MS Project imports up to 80 MB. Must stay <= 100 MB
# (DATA_UPLOAD_MAX_MEMORY_SIZE / nginx client_max_body_size).
MSPROJECT_MAX_UPLOAD_MB=80

Imported files are stored base64-encoded in an ImportRequest row only until the import is processed, then purged on the schedule set by TRUEPPM_IMPORT_RETENTION_DAYS (default 7 days). See Outbox & Record Retention to tune that window.

Jira import accepts a Jira Server / Data Center XML export (.xml only). Like MS Project import, it is bounded on both size and row count:

VariableDefaultUnitWhat it bounds
JIRA_IMPORT_MAX_UPLOAD_MB25MBMaximum size of a single Jira XML import upload
JIRA_IMPORT_MAX_ROWS20000issuesMaximum issues a single Jira XML import may contain

The size default is lower than the MS Project cap because a Jira issue export is typically small and, like the MS Project importer, an upload is read fully into memory and stored base64-encoded in a single database row (about +33%) until the import is processed. Files larger than the cap are rejected with HTTP 400 before any parsing happens.

JIRA_IMPORT_MAX_ROWS exists for the same reason as MSPROJECT_MAX_ROWS above: the byte cap does not bound the derived task count, and every issue becomes a Task object built and bulk-created through the same shared importer (msproject.importer.import_project). An import past the row cap is rejected outright.

Terminal window
# Allow Jira XML imports up to 40 MB. Same edge caps apply as MS Project imports:
# keep it at or below DATA_UPLOAD_MAX_MEMORY_SIZE (100 MB) and your nginx
# client_max_body_size, or the request is rejected upstream before the importer.
JIRA_IMPORT_MAX_UPLOAD_MB=40

The Jira XML parse goes through defusedxml (no entity expansion, no external-entity resolution), so an XXE / billion-laughs payload is rejected at parse time — the same unconditional protection the MS Project importer has.

CSV / Excel import is bounded on three axes, because size alone does not bound the work an upload creates:

VariableDefaultUnitWhat it bounds
CSV_IMPORT_MAX_UPLOAD_MB10MBMaximum size of a single .csv / .xlsx upload
CSV_IMPORT_MAX_ROWS5000rowsMaximum data rows parsed from one file
CSV_IMPORT_MAX_UNCOMPRESSED_MB100MBMaximum uncompressed size an .xlsx may declare

The size cap is much lower than the MS Project one on purpose: a spreadsheet migration is a hand-maintained sheet, not a generated plan, and 10 MB is already far past what a real one weighs. Like the other importers, an upload is read fully into memory and stored base64-encoded in a single database row (about +33%) until it is processed.

The row cap is a separate limit for a reason: a 10 MB CSV can encode well over a million short rows, and the parser builds one task object per row before anything is written. Rows past the cap are reported back to the operator as skipped rather than silently dropped, so an import is never quietly partial.

Terminal window
# Allow larger sheets. Keep the upload cap at or below
# DATA_UPLOAD_MAX_MEMORY_SIZE (100 MB) and your nginx client_max_body_size,
# or the request is rejected upstream before the importer sees it.
CSV_IMPORT_MAX_UPLOAD_MB=25
CSV_IMPORT_MAX_ROWS=20000

An .xlsx file is a zip archive of XML. TruePPM parses it with openpyxl, which automatically enables defusedxml hardening (no entity expansion, no external-entity resolution) because defusedxml is installed — so XXE and billion-laughs payloads are rejected at parse time.

That protection does not bound decompression. A 1 MB upload can inflate to gigabytes and exhaust worker memory while sitting comfortably inside the byte cap. Before parsing, TruePPM sums the sizes declared in the zip’s central directory and rejects anything above CSV_IMPORT_MAX_UNCOMPRESSED_MB. The check reads no member data and runs before any XML parser is invoked.

A JSON program seed (POST /programs/import/, ADR-0109) has its own, much smaller size cap:

VariableDefaultUnitWhat it bounds
SEED_MAX_UPLOAD_MB5MBMaximum size of a single JSON program seed payload

Seeds are bounded relative to the other importers because they are a different kind of payload: the largest bundled sample seed is a few hundred KB, so 5 MB is generous headroom while still bounding the memory a single authenticated import request can consume. The ceiling is enforced on both request shapes — a multipart/form-data upload and a raw JSON body — so it cannot be sidestepped by posting the document as the body. It is measured against the bytes that actually arrived rather than a declared Content-Length, which a chunked request may omit. See general API rate limiting above for the separate TRUEPPM_THROTTLE_SEED_IMPORT_RATE / TRUEPPM_THROTTLE_SEED_VALIDATE_RATE throttles that bound how often a caller may hit this endpoint, independent of payload size.

The Community (OSS) tier bounds Monte Carlo risk analysis with three caps. Like the email settings above, these are Django settings constants, not environment variables — override them in a Django settings module; setting bare env vars of the same name has no effect. The Enterprise edition raises or removes them.

SettingDefault (OSS)What it bounds
MC_SIMULATION_CAP1000Maximum simulation runs (iterations) per request. The Monte Carlo run endpoint rejects an n_simulations above this.
MC_TASK_CAP5000Largest project — by task count — Monte Carlo will run on. The vectorized NumPy path handles 5000 tasks × 1000 runs in a few seconds; a larger project is refused rather than run unbounded.
MC_HISTORY_CAP100Forecast-history rows kept per project. The nightly purge trims each project to its newest MC_HISTORY_CAP MonteCarloRun rows.

Set any cap to None for unlimited — the Enterprise default, where unbounded forecast history plus cross-program rollup is part of the portfolio tier. Operators on constrained hardware can lower MC_TASK_CAP to keep simulations cheap.

# settings override — raise the task ceiling for a large-project deployment.
MC_TASK_CAP = 10_000

TRUEPPM_SECURE_SSL_REDIRECT controls whether the prod settings module issues an HTTP→HTTPS redirect for every request. It is opt-in and defaults to false for a reason that trips up a first deploy: most self-hosted installs terminate TLS at an ingress or load balancer and speak plain HTTP from there to the app pod, including the Kubernetes liveness/readiness probes hitting /api/v1/health/ and /api/v1/edition/. An unconditional redirect in that topology would 301-loop the probes and take the pod out of rotation.

VariableDefaultWhat it does
TRUEPPM_SECURE_SSL_REDIRECTfalseWhen true, prod redirects every non-HTTPS request to HTTPS, using SECURE_PROXY_SSL_HEADER (X-Forwarded-Proto) to detect the original scheme behind a proxy.

Turn it on only when TruePPM itself terminates TLS or otherwise receives the original request scheme reliably — for example, a deployment that exposes the app directly over HTTPS with no intervening proxy, or a proxy configured to forward X-Forwarded-Proto correctly. The Kubernetes health-probe paths (/api/v1/health/, /api/v1/readyz, /api/v1/edition/) are always exempt from the redirect regardless of this setting, so turning it on never breaks the probes even if they are reached over plain HTTP.

This setting has no effect outside trueppm_api.settings.prod — the dev settings module never enforces an HTTPS redirect.

A standard deploy serves the web app from the same origin as the API (e.g. nginx routes / to the SPA and /api to Django on one hostname). The secure defaults assume this and need no extra configuration.

A split-origin deploy serves the SPA from a different origin than the API — for example https://app.example.com (SPA) and https://api.example.com (API). Two security defaults are origin-aware and must be relaxed for the browser to talk to the API:

SettingWhy it must changeSet to
AUTH_REFRESH_COOKIE_SAMESITEThe refresh cookie is SameSite=Strict, so the browser will not send it on the cross-origin refresh request, and sessions silently fail to renew.Lax (or None if the SPA and API are on unrelated sites — None requires HTTPS, which the Secure flag already enforces).
CSP_CONNECT_SRCThe Content-Security-Policy connect-src defaults to 'self' wss:. With the SPA on a different origin, the browser blocks XHR / WebSocket connections to the API origin.'self' https://api.example.com wss://api.example.com — add the API origin and its wss:// origin.

Also set CSRF_TRUSTED_ORIGINS (above) for any split-origin deploy.

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.

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

SettingDefaultEffect
Report a bug controlShownHides 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.

No manual steps are needed: the api container runs migrations and the create_admin bootstrap automatically on startup. Retrieve the generated admin password as described in Admin password setup.

The admin user can then authenticate via the API and create projects. When a user creates a project, they automatically become its Owner.