Skip to content

Networking

Everything between your users and the API: the DNS records to create, where TLS terminates, what a proxy has to forward for real-time collaboration to work, where a load balancer sits when you scale past one replica, which health-check path to point it at, and the full ingress/egress port matrix.

TruePPM runs on a single hostname. The SPA, the REST API, and the WebSocket endpoint are all served from the same origin, and every shipped topology routes them by path on one host:

Path prefixServed by
/the compiled React SPA (nginx)
/api/the Django API
/ws/the Django Channels WebSocket endpoint (/ws/v1/projects/<uuid>/)
/static/Django’s collected static files (admin CSS, Swagger UI)

Four settings all name that one origin. They are read by different subsystems and nothing cross-checks them, so a mismatch surfaces later as a 400, a broken email link, or an OIDC redirect_uri your identity provider rejects — never as a startup error:

VariableFormatRead byIf it disagrees
DOMAINbare hostname, no scheme — trueppm.example.comDocker Compose only: envsubst into the nginx server_name and the certificate paths, and certbot -dnginx serves the default server or fails to find the certificate; ACME issues for the wrong name
ALLOWED_HOSTScomma-separated bare hostnamesDjango, on every requestHTTP 400 on every request whose Host header is not listed — including health checks that connect by IP (see Health checks)
TRUEPPM_FRONTEND_BASE_URLfull origin with scheme, no trailing slash, no path — https://trueppm.example.comnotification email deep-linksemails link to the wrong host, or omit links entirely when unset
TRUEPPM_PUBLIC_API_BASE_URLfull origin with scheme, no trailing slash — same origin as aboveOIDC only: builds {base}/api/v1/auth/oidc/callback/your IdP refuses the login with a redirect_uri mismatch

TRUEPPM_PUBLIC_API_BASE_URL is empty by default, which falls back to the incoming request’s absolute URL. That is correct only when the SPA and the API are genuinely reached at one origin — the production single-server Compose topology above, or any deploy proxied so Host reaches Django unchanged — and wrong behind a proxy that rewrites Host, where the value then depends on forwarded headers rather than on anything you set. Set it explicitly on any proxied deploy so the value your IdP allow-lists is deterministic. See Single sign-on.

This is not automatically true of the bundled local development stack (docker-compose.yml, make up) — that stack runs the SPA (Vite, :5173) and the API (:8000) on two different ports, and Vite’s dev proxy rewrites Host on the way through exactly like a reverse proxy would. docker-compose.yml therefore pins both TRUEPPM_PUBLIC_API_BASE_URL and TRUEPPM_FRONTEND_BASE_URL explicitly rather than relying on the empty default — see Testing SSO against the local development stack.

Serving the SPA at app.example.com and the API at api.example.com does not work. TruePPM ships no CORS support — there is no django-cors-headers dependency and no CORS_* setting, so Django never emits an Access-Control-Allow-Origin header and the browser blocks every cross-origin XHR and WebSocket upgrade from the SPA. CSRF_TRUSTED_ORIGINS, CSP_CONNECT_SRC, and AUTH_REFRESH_COOKIE_SAMESITE address CSRF, CSP, and cookie scope — three different layers, none of them CORS — so relaxing them changes nothing about the block. Use one hostname.

A wildcard certificate and a wildcard DNS record are not required. One name is enough.

One A record (and one AAAA if you serve IPv6) pointing at whatever terminates TLS — the Compose host’s public IP, your load balancer, or the ingress controller’s external address:

; example.com zone file
$ORIGIN example.com.
$TTL 300
trueppm IN A 203.0.113.42
trueppm IN AAAA 2001:db8::2a
; Optional but recommended: pin which CAs may issue for this name.
; Required if you use ACME (Let's Encrypt) — issuance fails if a CAA
; record exists and does not list the CA.
trueppm IN CAA 0 issue "letsencrypt.org"
trueppm IN CAA 0 iodef "mailto:ops@example.com"

Keep the TTL low (300s) until the install is verified, then raise it.

On Kubernetes, do not hand-maintain the record if you can avoid it — point external-dns at the same hostname you put in ingress.hosts[0].host, so the record and the Ingress cannot drift.

Verify before installing. ALLOWED_HOSTS rejects anything else, and ACME HTTP-01 validation needs the name to already resolve:

Terminal window
dig +short trueppm.example.com A
dig +short trueppm.example.com AAAA

If TruePPM resolves to a private address on the inside and a public one on the outside, nothing in the app cares — it only ever sees the Host header. But TruePPM’s outbound SSRF guard does care: every host TruePPM connects to must resolve to a globally routable address, or the request is refused. That blocks an in-cluster identity provider (keycloak.sso.svc), an internal SMTP relay, or a MinIO or SeaweedFS endpoint on a private VIP.

TRUEPPM_EGRESS_ALLOWLISTED_HOSTS is the escape hatch — a comma-separated list of exact, case-insensitive hostnames exempted from that check:

Terminal window
TRUEPPM_EGRESS_ALLOWLISTED_HOSTS=keycloak.sso.svc.cluster.local,smtp.internal.example.com

No wildcards. The allow-list is consulted by every egress caller (SSO, webhooks, SMTP, integration credential verification), so only list a host you trust at least as much as your identity provider. See Outbound requests.

The Helm web tier’s nginx proxies /api/ and /ws/ to the API Service by name. The chart’s fullname helper collapses the release name when it already contains trueppm, so helm install trueppm ... yields trueppm-api and helm install foo ... yields foo-trueppm-api:

ObjectIn-cluster namePort
API Service<release>-api (or <release>-trueppm-api)8000 (service.port)
Web Service<release>-web (or <release>-trueppm-web)80 (service.web.port), container 8080

TruePPM never terminates TLS itself. The API container speaks plain HTTP on :8000, always. Something in front of it — the bundled nginx, an ingress controller, or a cloud load balancer — owns the certificate.

TopologyTLS terminates atMust forwardTRUEPPM_SECURE_SSL_REDIRECTHSTS owner
Compose + bundled nginx (TLS_MODE=letsencrypt / selfsigned)the stack’s nginx, on :443nothing to configure — nginx/app.conf.template already sets Host, X-Real-IP, X-Forwarded-For, X-Forwarded-Proto and the WebSocket upgrade headersleave false — nginx already 301s :80 → :443the nginx template (max-age=63072000; includeSubDomains)
Compose behind an external LB (TLS_MODE=none)your load balancerLB → nginx: Host, X-Forwarded-For, X-Forwarded-Proto, Upgrade, Connection. See the caveat below — an old copy of the HTTP template may still overwrite X-Forwarded-Protoleave false — the LB does the redirectthe load balancer
Helm + ingress-nginxthe Ingress (ingress.tls[].secretName)ingress-nginx sets X-Forwarded-For / X-Forwarded-Proto and preserves Host itself; add the WebSocket timeout annotations from WebSocketsleave false — set nginx.ingress.kubernetes.io/ssl-redirect: "true" instead (it is the controller default when a TLS block exists)ingress-nginx (hsts: true in its ConfigMap) and web.securityHeaders.strictTransportSecurity for the SPA document
Helm behind a cloud LB (ALB / GCLB), Ingress in HTTP modethe cloud load balancerthe LB must send X-Forwarded-Proto: https and preserve Host. ALB and GCLB both do by default. Keep the backend protocol on HTTP/1.1 so the WebSocket upgrade survivesleave falsethe cloud LB, plus web.securityHeaders.strictTransportSecurity
Helm + cert-managerthe Ingress, with the Secret provisioned automaticallyas ingress-nginx aboveleave falseas ingress-nginx above

Every row needs the original Host preserved, not just the two Compose ones. X-Forwarded-Host is ignored — see the caution below for what to do when your edge rewrites Host.

TRUEPPM_SECURE_SSL_REDIRECT stays false in every supported topology. Turn it on only when TruePPM receives the original scheme reliably and nothing in front of it already redirects — otherwise you get a redirect loop. The probe paths (/api/v1/health/, /api/v1/readyz, /api/v1/edition/) are exempt from the redirect regardless, so enabling it never breaks a health check. See TLS redirect posture.

X-Forwarded-For has a quieter failure, and it has two causes that look identical from the outside. DRF’s per-IP throttles key on the client address TRUEPPM_NUM_PROXIES hops back in X-Forwarded-For (default 1). Get either the header or the hop count wrong and every request arrives with the same apparent source address, so the per-IP login and anonymous throttles collapse into one shared bucket — which does not merely fail to throttle an abuser, it lets one client spend everybody’s budget.

Cause 1 — the header is absent. The production branch of the Helm web tier’s nginx sets Host, X-Real-IP and Authorization but not X-Forwarded-For, so routing /api through the web tier leaves DRF reading REMOTE_ADDR — the web pod’s own peer. The default ingress.hosts sends /api and /ws straight to the API Service, where this does not arise. If you change that, add the header at your edge.

Cause 2 — the header is present and the hop count is too low. The demo branch of that same nginx does set X-Forwarded-For, with $proxy_add_x_forwarded_for, which appends its own peer to whatever arrived. So the header being there is not enough: NUM_PROXIES has to match how many proxies actually sit in front of Django, and it is the last NUM_PROXIES entries that get skipped.

Count the hops on the path the request really takes:

Path to DjangoProxiesTRUEPPM_NUM_PROXIES
Ingress → API Service (the default ingress.hosts), nothing in front of the ingress controller11
Cloud load balancer or CDN → ingress-nginx → API Servicesee below1 or 2
Ingress → web tier → API Service22
Cloudflare Tunnel → web tier → API Service (demo mode)22
Docker Compose production stack (nginx → api)11
Development docker-compose.yml (:8000 directly, or the Vite proxy on :5173)00, set in the file
Client → API Service, nothing in front00

A proxy only counts if it appends to X-Forwarded-For. The Vite dev server’s proxy passes the header through without adding to it, so the development stack counts as zero proxies even when you browse through :5173.

Demo mode is always in the two-hop rows: it refuses an Ingress path that reaches the API Service directly, so /api/v1/share/ necessarily crosses the web tier. values-demo.yaml therefore overrides this to 2. At 1 the demo’s only public endpoint shares a single 60/min bucket across every visitor on the internet, and one crawler answers 429 to everyone else.

Set it too high and you have the opposite problem: a client can prepend its own X-Forwarded-For and choose its own throttle bucket. With nothing in front of Django and the default of 1, rotating that one header gives a client a fresh login brute-force budget per value. The per-account login_account limit still applies, but the per-IP limit stops working. Match the number to the deployment, and re-check it whenever you change what sits in front of the API.

TruePPM refuses to start on a negative TRUEPPM_NUM_PROXIES, because DRF would then read the header from its client-written end.

A load balancer in front of the ingress controller

Section titled “A load balancer in front of the ingress controller”

This is the ordinary production shape on a managed cloud (AWS ALB or NLB, Google Cloud Load Balancing, Azure Application Gateway, Cloudflare proxying the hostname). It is also where the default of 1 quietly stops being right. ingress-nginx is configured by its controller ConfigMap, not by this chart:

  • use-forwarded-headers: "false" (the ingress-nginx default). The controller discards the incoming X-Forwarded-For and sends only the address it saw, which is the load balancer’s. No value of TRUEPPM_NUM_PROXIES recovers the visitor, so every client shares one bucket. This is fine only for an L4 load balancer that preserves the source address (an NLB or type: LoadBalancer Service with externalTrafficPolicy: Local, or PROXY protocol with use-proxy-protocol). In that case the controller really does see the visitor, and 1 is right.
  • use-forwarded-headers: "true" and proxy-real-ip-cidr set to the load balancer’s address range. The controller works out the client address itself, trusting the header only from the range you named, and forwards that single address. Keep TRUEPPM_NUM_PROXIES at 1. This is the setup we recommend, because the trust decision is made by address range at the edge rather than by counting hops. Do not leave proxy-real-ip-cidr at its default of 0.0.0.0/0. With every address trusted, the controller walks the header back to its leftmost entry, which the client wrote, and you are in the too-high case again.
  • compute-full-forwarded-for: "true". The controller appends to the chain instead of replacing it, so Django sees one more entry per proxy. Set TRUEPPM_NUM_PROXIES to the full count, usually 2, and make sure nothing except the load balancer can reach the controller.

A CDN in front of a cloud load balancer adds a hop again. Don’t work the count out from a diagram. Read it back from the running deployment, as described in the next section.

The address the throttles actually used can’t be seen from outside, so check it directly. A workspace operator can call the System Health endpoint, and its security.client_address block shows how their own request was resolved:

Terminal window
curl -s -H "Authorization: Bearer $TOKEN" \
https://pm.example.com/api/v1/health/system/ | jq .security.client_address
{
"num_proxies": 1,
"resolved": "198.51.100.23",
"remote_addr": "10.42.1.7",
"forwarded_for": ["198.51.100.23"],
"forwarded_for_entries": 1,
"status": "ok",
"detail": "Nothing wrong is provable from this request. …"
}

resolved is the address every per-IP throttle keyed this request on. Run two checks against it:

  1. It is your address. Compare it with the public address you are connecting from. If it is a private or load-balancer address, the value is too low.
  2. You cannot change it. Repeat the call with -H "X-Forwarded-For: 192.0.2.1". If resolved becomes 192.0.2.1, the value is too high.

status flags the cases one request is enough to prove:

statusMeaning
okNothing provably wrong. Still run both checks above.
fewer_hops_than_configuredFewer X-Forwarded-For entries arrived than TRUEPPM_NUM_PROXIES says proxies exist. The value is too high for this path, or a proxy on it does not set the header.
forwarded_for_ignoredThe value is 0, but the header arrived. If a proxy wrote it, every client shares that proxy’s bucket.
non_publicresolved is a private, loopback or link-local address. That is correct from inside your network, but if you came in over the internet it is a proxy’s address.
unparsableThe position the value selects doesn’t hold an IP address.

The block describes the path your request took. If /api reaches Django by more than one path (for example straight from the ingress and also through the web tier), check each path.

The client_address block is added after v0.4.0-beta.3, and is absent on that tag and earlier.

ClusterIssuer is cluster-scoped, so one object serves every namespace. Pick HTTP-01 if the hostname is reachable from the public internet on port 80, and DNS-01 if it is not (or if you want a wildcard).

HTTP-01 — cert-manager creates a temporary Ingress to answer the challenge, so ingress.class must match the controller that owns your hostname:

clusterissuer-http01.yaml
apiVersion: cert-manager.io/v1
kind: ClusterIssuer
metadata:
name: letsencrypt-prod
spec:
acme:
server: https://acme-v02.api.letsencrypt.org/directory
email: ops@example.com
# cert-manager creates this Secret and stores the ACME account key in it.
privateKeySecretRef:
name: letsencrypt-prod-account-key
solvers:
- http01:
ingress:
class: nginx
Terminal window
kubectl apply -f clusterissuer-http01.yaml
kubectl get clusterissuer letsencrypt-prod -o wide # READY should be True

DNS-01 — no inbound port 80 needed, works for internal-only hostnames and wildcards. Route 53 shown; every provider differs only in the solver block:

Terminal window
kubectl create secret generic route53-credentials \
--namespace cert-manager \
--from-literal=secret-access-key='<AWS secret access key>'
clusterissuer-dns01.yaml
apiVersion: cert-manager.io/v1
kind: ClusterIssuer
metadata:
name: letsencrypt-dns
spec:
acme:
server: https://acme-v02.api.letsencrypt.org/directory
email: ops@example.com
privateKeySecretRef:
name: letsencrypt-dns-account-key
solvers:
- dns01:
route53:
region: us-east-1
hostedZoneID: Z0123456789ABCDEFGHIJ
accessKeyID: AKIAIOSFODNN7EXAMPLE
secretAccessKeySecretRef:
name: route53-credentials
key: secret-access-key
# Only solve for names in this zone; other names fall through to
# another solver. Drop the selector if this issuer serves everything.
selector:
dnsZones:
- example.com

Pair either issuer with the chart’s Ingress. The annotation names the issuer; cert-manager watches the Ingress, sees the tls block, and creates the trueppm-tls Secret for you — you never run kubectl create secret tls:

my-values.yaml
ingress:
enabled: true
className: nginx
annotations:
cert-manager.io/cluster-issuer: letsencrypt-prod # or letsencrypt-dns
# Keep the chart's own defaults; Helm deep-merges this map, but naming a
# key here replaces it, so restate the three below rather than dropping
# them. All three ship as chart defaults already (values.yaml) — shown
# here only because adding cert-manager.io/cluster-issuer to this map
# would otherwise silently keep them (deep-merge), which is correct, but
# worth restating explicitly since this file overrides the whole
# `ingress.annotations` key by example elsewhere on this page.
nginx.ingress.kubernetes.io/proxy-body-size: "110m"
# WebSocket lifetime — see the timeout table below.
nginx.ingress.kubernetes.io/proxy-read-timeout: "3600"
nginx.ingress.kubernetes.io/proxy-send-timeout: "3600"
hosts:
- host: trueppm.example.com
paths:
- path: /api
pathType: Prefix
service: api
- path: /ws
pathType: Prefix
service: api
- path: /
pathType: Prefix
service: web
tls:
- secretName: trueppm-tls
hosts:
- trueppm.example.com

Watch the issuance; a stuck certificate almost always names its own cause:

Terminal window
kubectl -n trueppm get certificate trueppm-tls
kubectl -n trueppm describe certificate trueppm-tls
kubectl -n trueppm get challenges # empty once validation completes

If your certificate comes from an internal CA or a commercial vendor, create the Secret yourself and skip cert-manager entirely. The Secret must be in the same namespace as the Ingress:

Terminal window
kubectl create secret tls trueppm-tls \
--namespace trueppm \
--cert=fullchain.pem \
--key=privkey.pem

fullchain.pem must be the leaf certificate followed by every intermediate, in that order. A leaf-only file validates in a browser that already caches the intermediate and fails everywhere else.

Then reference it, with no cert-manager.io/cluster-issuer annotation:

ingress:
enabled: true
className: nginx
tls:
- secretName: trueppm-tls
hosts:
- trueppm.example.com

Renewal is yours: replace the Secret (kubectl create secret tls ... --dry-run=client -o yaml | kubectl apply -f -) before expiry. ingress-nginx picks up the change without a restart.

Bring your own certificate — Docker Compose

Section titled “Bring your own certificate — Docker Compose”

TLS_MODE has three values (letsencrypt, selfsigned, none) and none of them means “use the certificate I already have”. The HTTPS nginx template reads a fixed pair of paths, so the supported way in is to put your files there:

certbot/conf/live/${DOMAIN}/fullchain.pem
certbot/conf/live/${DOMAIN}/privkey.pem

Order matters, because init-prod.sh overwrites both files on the selfsigned path and replaces nginx/active.conf.template on every path:

Terminal window
# 1. First boot only. TLS_MODE=selfsigned selects the HTTPS nginx template and
# leaves the certbot renewal profile OFF — both of which is what you want.
# It also writes a throwaway self-signed pair, which step 2 replaces.
./init-prod.sh
# 2. Install the real certificate over it.
cp /path/to/fullchain.pem certbot/conf/live/"${DOMAIN}"/fullchain.pem
cp /path/to/privkey.pem certbot/conf/live/"${DOMAIN}"/privkey.pem
# 3. Reload nginx in place — no downtime, no container restart.
docker compose -f docker-compose.prod.yml exec nginx nginx -s reload

The nginx container already reloads itself every 6 hours, so replacing the files and waiting is also valid — the explicit reload just makes it immediate.

Real-time collaboration runs over wss://<host>/ws/v1/projects/<uuid>/. The connection is authenticated by a single-use ticket on the query string (?ticket=...), minted by POST /api/v1/ws/ticket/.

Any proxy in the path must speak HTTP/1.1 upstream and pass the upgrade handshake through. This is the shipped block from nginx/app.conf.template, annotated:

# Maps the inbound Upgrade header to the right Connection value. Without the
# map, a `Connection: upgrade` hard-coded on non-WebSocket requests breaks
# keepalive; with it, normal HTTP gets `Connection: close`.
map $http_upgrade $connection_upgrade {
default upgrade;
'' close;
}
location /ws/ {
proxy_pass http://api:8000;
# REQUIRED. The Upgrade mechanism does not exist in HTTP/1.0, which is
# nginx's default to upstreams — without this the handshake never happens
# and the client sees a plain 200 or 400 instead of a 101.
proxy_http_version 1.1;
# REQUIRED. These two carry the handshake itself.
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection $connection_upgrade;
# REQUIRED. Django validates this against ALLOWED_HOSTS on the WebSocket
# path too; nginx would otherwise send the upstream's name ("api").
proxy_set_header Host $host;
# Client identity for the per-IP throttles (see TRUEPPM_NUM_PROXIES).
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
# A WebSocket carries no traffic while a project is quiet. The default
# 60s would close it; this is a connection lifetime, not a request timeout.
proxy_read_timeout 86400s;
}

Every proxy closes a connection that looks idle. The knob is different on each one, and on some of them it is not an idle timeout at all:

ProxyKnobDefaultSet to
nginx (standalone)proxy_read_timeout / proxy_send_timeout60s3600s or more
ingress-nginxnginx.ingress.kubernetes.io/proxy-read-timeout / proxy-send-timeout (seconds, unquoted number as a string)60"3600"
AWS ALBidle_timeout.timeout_seconds, via alb.ingress.kubernetes.io/load-balancer-attributes603600 (ALB maximum 4000)
GCP external HTTP(S) LBbackend service timeoutSec, via a BackendConfig303600
HAProxytimeout tunnel (haproxy.org/timeout-tunnel on the HAProxy Ingress Controller)inherits timeout client/timeout server3600s

Recommended floor: 3600 seconds. A user with the schedule open in a background tab over lunch is a normal case, and a reconnect storm from a whole team timing out together is not.

Why idle sockets survive a 60s default today

Section titled “Why idle sockets survive a 60s default today”

Nothing in the TruePPM client sends a heartbeat — the project socket is receive-only, and the presence keepalive re-arms a Redis TTL without putting a byte on the wire. What actually keeps an idle connection alive is uvicorn’s protocol-level ping:

  • ws_ping_interval defaults to 20.0 — uvicorn sends a WebSocket PING frame every 20 seconds on every open socket.
  • ws_ping_timeout also defaults to 20.0 — if the matching PONG does not come back within 20 seconds, uvicorn closes the connection.

The API image runs bare uvicorn with no override for either, so both defaults apply. That 20-second ping is the only reason a 60-second proxy default does not drop every socket, and it is load-bearing rather than deliberate. Two rules follow:

  1. Never set --ws-ping-interval above your proxy’s idle timeout. Raising it to “reduce chatter” reintroduces exactly the disconnect it is preventing.
  2. Do not buffer the WebSocket location. A proxy that delays PONGs by more than 20 seconds makes the server drop the client — a disconnect that looks like a network fault and is not.

Session affinity is not required, and you should not configure it. Any replica can serve any socket:

  • The channel layer is channels_redis.core.RedisChannelLayer, backed by the same Valkey every replica shares. There is no in-memory layer anywhere in the settings tree.
  • Group names derive from the project UUID (project_<uuid>), not from pod identity, so a broadcast reaches subscribers on every replica.
  • The WebSocket ticket is written to Valkey and consumed with GETDEL, so a ticket minted by the pod that answered POST /api/v1/ws/ticket/ is redeemable on any other pod — and exactly once.
  • Celery-originated broadcasts (cpm_complete and friends) go through the same channel layer, so a worker’s result reaches clients regardless of which replica they are attached to.

The one requirement is that all replicas share one logical Valkey. Point them at the same instance (or the same Sentinel set); do not give each replica its own cache.

WebSockets over HTTP/2 need the RFC 8441 Extended CONNECT protocol, which most ingress controllers and cloud load balancers do not implement, and HTTP/3 needs RFC 9220. In practice:

  • Browser → edge over HTTP/2 is fine. A browser that cannot upgrade over h2 falls back to HTTP/1.1 for the WebSocket request specifically.
  • Edge → TruePPM must be HTTP/1.1. That is what proxy_http_version 1.1 guarantees on the nginx path. On a cloud LB, leave the backend protocol at HTTP/1.1 for the /ws target group or backend service — forcing HTTP/2 or gRPC there breaks the upgrade with no useful error.
What you seeCauseFix
Closes immediately, code 4003The authenticated user’s role on that project is Viewer (or they are not a member). Not a networking fault. The web app does not normally dial for a Viewer at all — it shows No live updates, and tasks and the forecast refresh every 5 minutes.Grant Team Member or above; see RBAC.
Closes immediately, code 4001No valid credential: the ticket is missing, expired, or already redeemed. Tickets are single-use, so a client that reuses one across reconnects fails on the second attempt.Mint a fresh ticket per connection.
Closes immediately, code 4404 (a browser shows 1006: this one is refused before the handshake is accepted)The path matched no route.Check the edge is not rewriting or stripping the /ws prefix — /ws/v1/projects/<uuid>/ must arrive intact.
Handshake never completes; you get HTTP 200 with the SPA’s HTML, or a 400The proxy stripped Upgrade/Connection, or spoke HTTP/1.0 upstream, or /ws fell through to the SPA’s try_files catch-all.Add the three required headers and proxy_http_version 1.1; route /ws before /.
Connects fine, then 1006 at a regular interval, all users togetherA proxy idle or lifetime timeout below the connection’s real lifetime. On GCP this is timeoutSec and pings will not save you.Raise the timeout from the table above.
Connects, then the server drops after 20–40 seconds of quietPONGs are being buffered past ws_ping_timeout (20s).Disable response buffering on the WebSocket location.
Works on one replica, breaks after scaling outReplicas do not share a Valkey, or two different Valkey instances are configured.Point every replica at one instance. Do not reach for session affinity.

path: root

path: api and ws

Browsers and mobile clientsHTTPS and WSS, one hostname

Edge: ingress controller, cloud LB,or the Compose nginxTLS terminates here

Web tier, nginxserves the SPAN replicas, stateless

API tier, uvicornREST and WebSocketN replicas, stateless

Celery workersCPM and Monte CarloN replicas

Celery beatexactly 1 replica

PostgreSQLmanaged

Valkeybroker, channel layer,WS tickets, locks

Object storageattachments

The edge is the only externally-facing object. Both Services stay ClusterIP; nothing below the edge should be reachable from outside the cluster — see the X-Forwarded-Proto caveat above for why that is a security property and not just tidiness.

Prerequisites for more than one API replica

Section titled “Prerequisites for more than one API replica”

Everything in the API tier is stateless except what is listed here. Work through it before raising replicaCount:

  • One hostname, one origin. All replicas answer on the same name; the four variables above agree.
  • A shared PostgreSQL. Use a managed instance; the bundled sub-chart is dev/demo only. Watch the connection ceiling — see Sizing.
  • One shared Valkey. It is the Celery broker, the Channels layer, the WebSocket ticket store, and the per-project scheduling lock. Splitting it breaks real-time collaboration and duplicates CPM runs.
  • Shared storage for attachments — object storage (S3, MinIO, SeaweedFS, Ceph) or a ReadWriteMany volume. This is a hard requirement above one replica, not a durability nicety: with attachments on each pod’s local disk, a file uploaded through replica A returns 404 when the download lands on replica B. Set TRUEPPM_DEFAULT_FILE_STORAGE and TRUEPPM_S3_BUCKET_NAME — see object storage.
  • Celery beat stays at exactly one replica. It fires the periodic drains; two overlapping beats double-dispatch every job. The chart pins it to one replica with a Recreate strategy.
  • No session affinity. See above — configuring it only concentrates load.
  • A PodDisruptionBudget (podDisruptionBudget.enabled=true) so a node drain cannot take every API replica at once.
  • WebSocket timeouts raised at the edge (previous section) before the reconnect load of a larger user base finds them.

Migrations are not a concern: they run in an initContainer before the API container starts, and Django’s migration table makes concurrent runs safe.

Health checks for an external load balancer

Section titled “Health checks for an external load balancer”
PathAuthWhat it checksCodesUse it for
/api/v1/health/none (AllowAny)Nothing. It returns {"status": "ok"} if the process is running and can route a request.200 onlyLiveness. A load balancer target check where you want “is the process up”.
/api/v1/readyznone (AllowAny)Database reachable, cache reachable, and every migration shipped in the image applied.200 ready, 503 not readyReadiness — the right choice for an external LB’s target-group health check. It removes a pod whose datastore is down or whose schema and code disagree mid-upgrade.
/api/v1/edition/none (AllowAny)Nothing. Returns edition, version, and build SHA.200 onlyBuild identification, not health.
/api/v1/health/beat/workspace operator only (IsWorkspaceOperator)Celery Beat heartbeat freshness.200 / 503Authenticated monitoring (Prometheus with a token). Not usable as an LB probe — it 403s without credentials.
/api/v1/health/system/workspace operator only (IsWorkspaceOperator)Deep system status for the admin UI.200Admin diagnostics. Not an LB probe.
/api/v1/health/dead-letter/, /api/v1/health/email/workspace operator only (IsWorkspaceOperator)Dead-letter and email gauges, Prometheus text exposition.200Scrape targets, with credentials. Not LB probes.

A worked ALB example, on the chart’s Ingress:

ingress:
annotations:
alb.ingress.kubernetes.io/healthcheck-path: /api/v1/readyz
alb.ingress.kubernetes.io/success-codes: "200"
alb.ingress.kubernetes.io/load-balancer-attributes: idle_timeout.timeout_seconds=3600

Verify a probe path by hand before wiring it up — from inside the cluster, with the Host header set the way the LB will send it:

Terminal window
kubectl -n trueppm run curl --rm -it --image=curlimages/curl --restart=Never -- \
curl -sS -o /dev/null -w '%{http_code}\n' \
-H 'Host: trueppm.example.com' \
http://trueppm-api:8000/api/v1/readyz

200 is ready. 503 is a real dependency failure — read the body, which names which check failed. 400 means the Host header did not match ALLOWED_HOSTS.

The chart’s NetworkPolicy locks down the bundled datastore pods and deliberately leaves the API and worker pods unrestricted in both directions — their outbound endpoints (your IdP, your SMTP relay, your object store) are deployment-specific, so a chart-imposed allow-list would break real installs. That work is yours to do at the platform layer, and this is the list to do it from. See Datastore network isolation for what the chart already covers.

One exception: with demo.interactive: true, the chart does restrict the api and celery-worker pods’ egress, to DNS and the bundled datastores only — this deployment shape needs no other outbound access at all. See Interactive demo mode.

SourceDestinationPort / protocolRequired?Why
Browsers, mobile clientsedge (ingress controller / cloud LB / Compose nginx)443/tcpYesHTTPS and WSS. The only port that must be open to users.
Browsersedge80/tcpYes for ACME HTTP-01; otherwise optionalServes /.well-known/acme-challenge/ and the 301 to HTTPS. Close it only if you use DNS-01 and accept that http:// gives no redirect.
Let’s Encrypt validation serversedge80/tcp, from anywhereOnly with ACME HTTP-01The CA connects from unpredictable addresses; it cannot be source-restricted.
Edgeweb Service / web tier pods80/tcp (container 8080)Yes when the web tier is enabledServes the SPA.
EdgeAPI Service / API pods8000/tcpYesREST and WebSocket. Must be reachable only from the edge — see the X-Forwarded-Proto caveat.
kubelet (node)API pods8000/tcpYes (Kubernetes)Liveness and readiness probes.
API, Celery worker, Celery beat, backup CronJob, demo-seed JobPostgreSQL5432/tcpYesPrimary data store. Never expose to the internet.
API, Celery worker, Celery beat, demo-seed JobValkey6379/tcpYesBroker, channel layer, WebSocket tickets, scheduling locks. Never expose to the internet.
Prometheus / your monitoringAPI8000/tcpOptionalScrapes /api/v1/health/beat/, /health/dead-letter/, /health/email/ — all staff-authenticated, so the scraper needs a token.
OperatorsAPI pod, via kubectl port-forward8000/tcp (local)OptionalThe supported route to Django admin; no firewall rule needed.

TruePPM’s SSRF guard already refuses any destination that resolves to a non-globally-routable address unless it is in TRUEPPM_EGRESS_ALLOWLISTED_HOSTS. That is an application control, not a network one — you still need the firewall rules below.

SourceDestinationPort / protocolRequired?Why
API, worker, beatDNS resolver53/udp, 53/tcpYesResolving every destination below.
Kubernetes nodesGitLab Container Registry (registry.gitlab.com)443/tcpYes, at pull time (Helm default)Pulling the API and web images from the chart’s default image.repository / image.webRepository. Opening only ghcr.io leaves a stock helm install in ImagePullBackOff. Mirror the images internally for an air-gapped install and point those values at the mirror.
Docker host (Compose)GHCR (ghcr.io)443/tcpYes, at pull time (Compose)docker-compose.prod.yml pulls the API and web images from ghcr.io/trueppm/{api,web}. Kubernetes nodes need this only if you override the chart’s image repositories to GHCR.
Whoever runs helm install / helm upgradeGHCR (ghcr.io)443/tcpIf you install the chart from its OCI artifactPulling oci://ghcr.io/trueppm/charts/trueppm. This is the workstation or CI runner running Helm, not the cluster nodes. Not needed when you install from a local or mirrored chart.
API, workerSMTP relayEMAIL_PORT, default 587/tcp (465 implicit TLS, 25 plain)If email notifications are enabledNotification and invitation email. EMAIL_USE_TLS defaults to true.
APIOIDC / OAuth2 identity provider443/tcpIf SSO is configuredDiscovery document, authorization, token exchange, JWKS. An in-cluster IdP needs TRUEPPM_EGRESS_ALLOWLISTED_HOSTS.
BrowsersOIDC identity provider443/tcpIf SSO is configuredThe login redirect happens in the user’s browser, not server-side — the client network needs this too.
API, workerS3 endpoint, MinIO, or SeaweedFS443/tcp (MinIO commonly 9000/tcp, SeaweedFS S3 gateway commonly 8333/tcp)If object storage is enabled — required above one replicaAttachment upload, download, and presigned URLs (TRUEPPM_S3_ENDPOINT_URL).
API, workerOTLP collector4317/tcp (gRPC) or 4318/tcp (HTTP)If OTEL_EXPORTER_OTLP_ENDPOINT is setTraces and metrics. Off by default — no endpoint, no egress.
API, workerSlack, and your own webhook receivers443/tcpIf webhooks or the Slack integration are configuredOutbound webhook delivery from the outbox.
API, workerJira / GitLab / self-hosted trackers443/tcpIf a user connects an external task sourceRead-only pull of a contributor’s assigned items.
certbot (Compose)acme-v02.api.letsencrypt.org443/tcpWith TLS_MODE=letsencryptIssuance and the 12-hourly renewal.
cert-manager (Kubernetes)ACME directory443/tcpWith cert-managerIssuance and renewal.
cert-manager (Kubernetes)DNS provider API (e.g. Route 53)443/tcpWith a DNS-01 solverWriting and deleting the _acme-challenge TXT record.
Backup CronJobyour backup destinationdestination-specificIf backups ship off-clusterSee Backup & restore.

An install with no email, no SSO, no telemetry, no webhooks, and object storage inside the cluster needs no outbound internet access at all once the images are pulled.

  • Deployment — Compose, Helm, and single-server walkthroughs
  • OpenShift Deployment — Ingress→Route conversion and SCC compatibility
  • Configuration — the full environment-variable reference
  • Helm values — every chart key
  • Sizing — replica counts and where the bottlenecks are
  • Security — CSP, headers, secrets, and the NetworkPolicy
  • Single sign-on — the OIDC redirect URI and in-cluster identity providers