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 for local dev and wrong behind a proxy, 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.

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 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 (web.service.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 — the HTTP template overwrites 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 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. ALB and GCLB both do. 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

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.

The missing X-Forwarded-For has a quieter failure. DRF’s per-IP throttles read the client address TRUEPPM_NUM_PROXIES hops back in X-Forwarded-For (default 1). The Helm web tier’s nginx sets Host, X-Real-IP and Authorization but not X-Forwarded-For, so if you route /api through the web tier instead of straight to the API Service, every request arrives with the same apparent source address and the per-IP login and anonymous throttles collapse into one shared bucket. The default ingress.hosts sends /api and /ws directly to the API Service, where this does not arise; if you change that, add the header at your edge.

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 default; Helm deep-merges this map, but naming the
# key here replaces it, so restate it.
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. Not a networking fault.Grant 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 4404The 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, 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/staff only (IsAdminUser)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/staff only (IsAdminUser)Deep system status for the admin UI.200Admin diagnostics. Not an LB probe.
/api/v1/health/dead-letter/, /api/v1/health/email/staff only (IsAdminUser)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.

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.
Nodes / Docker hostcontainer registry (ghcr.io)443/tcpYes, at pull timePulling the API and web images. Mirror it internally for an air-gapped install.
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 or MinIO443/tcp (MinIO commonly 9000/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
  • 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