Skip to content

Kubernetes

Parley ships no Postgres for Kubernetes, and the chart will not create one. Have these five things true before you run helm install. Four of them fail loudly rather than subtly — Parley logs a line starting FATAL: and exits, so kubectl logs has the answer. The exception is the shared-database one, which is why it is spelled out at length below.

  • PostgreSQL 13 or newer, with no upper bound. 13 is the floor because the first migration runs create extension if not exists pgcrypto. pgcrypto became a trusted extension in PostgreSQL 13, so any role with CREATE on the target database can install it there — no ownership and no superuser required. On 12 and below, installing it still needs superuser, and migration 0001 fails: FATAL: database migration failed. There is no ceiling — 14, 15, 16 and 17 are all fine. The compose file pins postgres:16-alpine because a compose file has to pin something, not because 16 is the supported version.

  • The database and the role already exist. Parley creates its schema, not the database and not the role. Create both yourself, and grant the role in DATABASE_URL CREATE on the database, e.g. GRANT CREATE ON DATABASE parley TO parley;. Ownership is not required.

  • That database is Parley’s alone. Sharing a Postgres server with other tenants is fine and normal. Sharing a database is not, for two reasons that have nothing to do with table-name collisions:

    Replicas talk to each other over LISTEN/NOTIFY on two fixed channel names, parley_session and parley_revoke. pg_notify is scoped to a database, not to a schema — so a second Parley installed into the same database receives the first one’s notifications, including session-token hashes on the revocation channel. The hash is useless as a credential and the same principal could already read it out of the tokens table, but “only this deployment hears it” is a property of the database boundary and of nothing else.

    Migrations serialize behind a PostgreSQL advisory lock, and advisory locks are scoped per-database — so a tenant in a different database on the same server cannot collide with Parley’s lock id, and a server shared that way is safe. Inside one database it is a live risk: Migrate takes the lock blocking, so a second Parley — or any application reusing the same id — in the same database holds the pod at boot with no error at all, just a pod that never becomes ready.

  • A verifying sslmode on DATABASE_URL. Parley refuses to start if sslmode is absent, disable, allow or prefer unless DATABASE_ALLOW_PLAINTEXT=true — those modes can talk to Postgres in the clear. Use ?sslmode=verify-full&sslrootcert=/etc/parley/ca/ca.pem. That path is inside the container; mount the CA there with the ConfigMap in Air-gapped. require still boots (it encrypts but never checks the server’s certificate); verify-ca checks the CA but not the hostname. A URI the server cannot complete a TLS handshake with still retries six times with doubling backoff — about a minute of postgres not reachable yet, retrying — before FATAL: could not connect to Postgres and a restart.

  • A quick sanity check from anywhere that can reach the database (set PGPASSWORD in the environment rather than putting it on the command line):

    Terminal window
    read -rs PGPASSWORD && export PGPASSWORD
    # sslrootcert is the CA file on the machine running psql, not a container path
    psql 'postgres://parley@host:5432/parley?sslmode=verify-full&sslrootcert=./ca.pem' \
    -c "select has_database_privilege('parley', 'parley', 'CREATE')" \
    -c 'select version()'

    A t result confirms the role can install pgcrypto. If that succeeds, Parley will migrate. The URI you store in the secret uses the in-pod path /etc/parley/ca/ca.pem after the ConfigMap mount below.

Both secrets hold one key whose value is the whole string. This is the most common way a first install fails: a secret built out of username / password / host keys leaves the pod in CreateContainerConfigError, because the key the container asks for is not there.

Secret Value name Key Contents
Database database.existingSecret database.secretKey, default database-url The entire connection URI
OIDC auth.oidc.existingSecret auth.oidc.secretKey, default oidc-client-secret The client secret, on its own

The OIDC secret is only for a confidential client. If your provider registered Parley as a public client — no secret at all, which Keycloak, Zitadel and Entra do by default for an app of this shape — set auth.oidc.publicClient=true instead and create no second secret. PKCE ties the authorization code to the browser that started the sign-in, so the flow is complete without one.

Terminal window
kubectl create secret generic parley \
--from-literal=database-url='postgres://parley:secret@host:5432/parley?sslmode=verify-full&sslrootcert=/etc/parley/ca/ca.pem'
# only with auth.mode=oidc
kubectl create secret generic parley-oidc \
--from-literal=oidc-client-secret='...'

sslrootcert=/etc/parley/ca/ca.pem is inside the container. The chart does not mount a CA by default — add the ConfigMap from Air-gapped. require still boots if you have not mounted one yet.

If the Secret comes from External Secrets, the Infisical operator, Vault Agent or similar, two behaviours are worth knowing before you debug the wrong thing.

CreateContainerConfigError is the normal first state, not a mistake. The chart’s secretKeyRef has no optional: true — a Parley that started without a database URL would only crash a moment later, less clearly. So while the operator is doing its first sync, the pod cannot be created and reports exactly the same error as a wrongly-shaped secret. It clears on its own, usually within a sync interval; kubelet retries without any help from you. Check which one you have before changing anything:

Terminal window
kubectl --namespace parley describe secret parley

describe lists the key names and their byte counts without printing the values — enough to answer this question, and safe in a terminal someone else may scroll back through. Nothing at all means the sync has not landed yet. A secret with username / password / host keys means the shape is wrong — Parley wants one key holding the entire URI. Under ArgoCD, give the secret an earlier sync wave.

A rotated secret does not restart the pods. Parley reads its configuration from environment variables once, at boot, and environment variables sourced from a Secret are resolved at container start — a later change to the Secret is not propagated to a running container by Kubernetes at all. When a rotation changes the database password or the OIDC client secret, roll the pods yourself:

Terminal window
kubectl --namespace parley rollout restart deploy/parley

Until then the old value stays live, which usually means the rotation looks successful right up to the next pod restart.

The chart is published to the same registry as the image. With the secret above in place:

Terminal window
helm install parley oci://ghcr.io/lets-parley/charts/parley --version 0.10.0 \
--set database.existingSecret=parley \
--set baseURL=https://parley.example.com

helm test parley then checks the Service actually routes to a ready pod. /readyz covers two things, so the test does too: the pod reached Postgres, and it is holding the Postgres LISTEN it hears other replicas on. It fails if either is down.

Re-run helm upgrade with the new chart version and keep the values you set last time:

Terminal window
helm upgrade parley oci://ghcr.io/lets-parley/charts/parley --version 0.10.0 \
--reuse-values
kubectl rollout status deploy/parley
helm test parley
curl -s https://parley.example.com/version

--reuse-values carries baseURL, database.existingSecret and everything else forward; add --set for anything you want to change in the same step. Pin --version on an upgrade for exactly the reason you pin it on install. /version is the check that the rollout actually replaced the pods, rather than the assumption that it did.

Migrations run themselves as the new pod boots, before the old pods are gone — see the window. Roll forward: rolling back onto an image older than the migrations that have run is refused, by design.

Without it Helm resolves to whatever the registry currently calls newest, so the command above means something different after every release. The chart version tracks the app version: 0.10.0 installs ghcr.io/lets-parley/parley:0.10.0. There is no v prefix on either — a v-prefixed tag has never been published.

Everything has a default except the database secret. The ones you will actually set:

  • database.existingSecret — the name of a secret holding the connection string, under database.secretKey (default database-url). No default, and no bundled Postgres.
  • baseURL — the address users type, exactly. Wrong, and the session cookie is silently dropped.
  • image.tag — empty tracks the chart’s appVersion, which always names a published release. Pin it only to move off that. The FIPS image is the same chart with --set image.tag=<version>-fips; latest-fips is refused the same way latest is. See Cryptography.
  • image.digest — empty keeps repository:tag. Set it to sha256:… to render repository@digest and ignore the tag, the same way tests.image.digest pins the helm test pod. See Air-gapped installs.
  • ingress.enabled, ingress.className, ingress.host, ingress.tls — off by default. The chart ships the proxy-read-timeout annotations WebSockets need for ingress-nginx; every other controller ignores them and needs its own setting. See below.
  • trustProxyHeaders and trustedProxyCIDRs — see below. On Kubernetes the second one has a trap in it.
  • networkPolicy.enabled — off by default. Turn it on whenever proxy trust is on; it is what makes a pod-CIDR trustedProxyCIDRs safe. See below.
  • networkPolicy.egress.enabled — off by default, ingress-only being the norm. For a cluster that requires default-deny egress, this adds egress rules for exactly the three peers Parley uses: DNS, Postgres, and the IdP. See below.
  • metrics.enabled — off by default. METRICS_ENABLED=true serves /metrics on the app port, unauthenticated. The Ingress path is / Prefix, so leaving it routed publishes the exposition. Admit the scraper’s pods with networkPolicy.extraIngress and keep the path off the public hostname; see Observability.
  • auth.mode (open or oidc) and auth.oidc.*. With oidc you must set either auth.oidc.existingSecret (confidential client; the secret is read from a Secret you create, never from values) or auth.oidc.publicClient=true (public client, no secret — PKCE carries the flow). Neither, or both, fails the render.

Every value is commented in deploy/charts/parley/values.yaml, which is the source of truth.

Booleans have to arrive as booleans. trustProxyHeaders, networkPolicy.enabled, ingress.enabled, serviceAccount.create, podDisruptionBudget.enabled, metrics.enabled and auth.oidc.publicClient are all read through a helper that treats only the literal string true as true, because a Go template treats any non-empty string — including "false" — as truthy, and --set-string, ArgoCD parameter overrides and CI templating pipelines all emit strings. The coercion means "false" behaves correctly; it also means "True", "yes" and a typo all read as false, without complaint. Pass real YAML booleans in a values file where you can.

Setting variables the chart has no knob for

Section titled “Setting variables the chart has no knob for”

Most of the configuration reference is reachable from a named value — baseURL, auth.mode, logLevel, the proxy-trust pair. A few variables are not: OIDC_SCOPES, and the seven abuse and resource limits. Those go through extraEnv, a list of Kubernetes EnvVar entries appended verbatim to the container after the chart’s own env block:

extraEnv:
- name: OIDC_SCOPES
value: "profile email groups"

valueFrom works too — each entry is a full EnvVar, not a string map. The variables themselves, and what each limit counts, live in the configuration reference; this page does not re-list them.

Last entry wins. Kubernetes takes the last env entry when a name repeats, and extraEnv is last. Chart-owned names that override this way include DATABASE_URL, BASE_URL, LOG_LEVEL, AUTH_MODE, POD_NAME, METRICS_ENABLED, the OIDC_* set, and the proxy-trust pair (TRUST_PROXY_HEADERS / TRUSTED_PROXY_CIDRS). Prefer the named values for those. Setting them here also skips the helm template refuses and NOTES NetworkPolicy warnings that read only the named values — so TRUST_PROXY_HEADERS=true in extraEnv can enable proxy trust while trustProxyHeaders stays false.

Keep DATABASE_URL on valueFrom / secretKeyRef if you must override it; a plain value: puts the connection string in the Deployment env. Flipping AUTH_MODE here can silently open an oidc instance while auth.mode still looks locked down — prefer auth.mode and database.existingSecret.

PORT is not one of the intended uses. Probes and containerPort target 8080; the Service maps service.port (default 80) to that target. Setting PORT in extraEnv leaves the probes on 8080 and fails readiness. service.port only changes the cluster-facing Service port, not the process listen port.

BIND_ADDR is not a chart value either. Pods bind all interfaces; NetworkPolicy is the control. Setting it in extraEnv would hide the process from probes and the Service.

extraVolumes/extraVolumeMounts are the volume-side counterpart, spliced into the pod spec and the container the same way extraEnv is. This is how a ConfigMap or Secret ends up mounted on disk — a private CA bundle, for example; see Air-gapped installs.

A malformed entry — a map instead of a list, or an entry with no name — fails at helm template time with a named error rather than rendering a manifest the API server will reject later. See below.

Parley speaks plain HTTP on 8080 and has no certificate handling of any kind. Terminating TLS is the ingress controller’s job; ingress.tls is passed through verbatim to the Ingress spec.tls, so it takes whatever your controller expects.

With cert-manager, that is a ClusterIssuer annotation plus a secret name for it to fill in:

baseURL: https://parley.example.com
ingress:
enabled: true
className: nginx
host: parley.example.com
annotations:
cert-manager.io/cluster-issuer: letsencrypt-prod
# Keep these — Parley pings every 25s and a shorter read timeout cuts
# live boards mid-round.
nginx.ingress.kubernetes.io/proxy-read-timeout: "75s"
nginx.ingress.kubernetes.io/proxy-send-timeout: "75s"
tls:
- hosts: [parley.example.com]
secretName: parley-tls

Setting annotations in your own values replaces the chart’s defaults rather than merging with them, which is why the two timeout annotations are repeated above. baseURL must be the https:// address to match — the chart refuses to render ingress.tls under an http:// base URL, because the session cookie’s Secure flag comes from baseURL and nothing else.

The two annotations above are nginx.ingress.kubernetes.io/*. Only ingress-nginx reads them. Any other controller silently ignores them and applies its own idle timeout, and if that timeout is shorter than Parley’s 25s ping, live boards get cut mid-round — which presents as an unstable network rather than as a configuration problem.

This matters more than it sounds, because k3s ships Traefik as its default ingress controller, so a homelab or edge cluster hits it without choosing it.

Traefik. The relevant timeouts are respondingTimeouts on the entry point, which is static configuration on the Traefik installation itself, not an annotation on your Ingress. In the k3s Traefik HelmChartConfig:

apiVersion: helm.cattle.io/v1
kind: HelmChartConfig
metadata:
name: traefik
namespace: kube-system
spec:
valuesContent: |-
ports:
websecure:
transport:
respondingTimeouts:
readTimeout: 300s
idleTimeout: 300s

idleTimeout is the one that matters for Parley: it only needs to exceed the 25s ping, and Traefik’s 180s default already does. readTimeout bounds how long a single request may take to arrive, and respondingTimeouts is static, per-entry-point configuration — it applies to every route through websecure, not only to the WebSocket ones.

That is why the example raises readTimeout rather than setting it to 0s. Zero means no limit, which a long-lived connection is happy with, but it removes the bound for ordinary HTTP on that entry point too and hands a slow-drip client a connection it can hold open. Parley’s own ReadTimeout does not cover you here — Traefik terminates the client connection first. There is no per-router override for these timeouts, so the value you pick applies to the whole entry point: choose one generous enough for a long call and still bounded, rather than removing the bound.

Traefik proxies WebSocket upgrades without any extra configuration — the timeouts are the only thing to get right.

Other controllers. HAProxy uses the haproxy.org/timeout-server annotation; Contour and Envoy-based controllers take a per-route idle timeout; a cloud load balancer in front of any of them has its own idle timeout that also has to clear 25s. Whatever yours is, the test is the same: open a board, leave it untouched for a few minutes, and see whether the client’s reconnect banner appears.

It refuses to render a deployment that cannot work

Section titled “It refuses to render a deployment that cannot work”

Each of these fails at helm template time with a message saying why, rather than producing a release that never becomes healthy. CI asserts every one of them:

  • A moving image taglatest, latest-fips, LATEST, Main, or one with trailing whitespace. Rolling back onto an image older than the migrations that have already run makes Parley refuse to start.
  • A tag that is not a valid image tag at all, including one shaped to break out of the quoted string it lands in inside the pod spec.
  • A fractional replica count. replicaCount must be a whole number: 1.5 would otherwise reach the API server as replicas: 1.5 and be rejected there with no useful message. More than one replica is supported; the default is 1, so upgrading the chart never silently doubles an install’s pods. 0 still renders — scaling to zero is how you take Parley down for a maintenance window.
  • A missing database.existingSecret.
  • OIDC with neither auth.oidc.existingSecret nor auth.oidc.publicClient set — or with both. A public client is supported; an unstated one is not, because a values merge that drops existingSecret would otherwise downgrade a confidential client to a public one in silence.
  • Ingress TLS under an http:// base URL.
  • A malformed extraEnv — a map instead of a list, or an entry with no name. See above.
  • networkPolicy.egress.enabled with no networkPolicy.egress.postgres.cidr — see above.

The chart also sets automountServiceAccountToken: false on both the ServiceAccount and the pod: Parley makes no Kubernetes API calls, so the token would only ever be a credential for something with a foothold to find.

deploy/k8s/deployment.yaml is a starting point. Two things in it are load-bearing rather than stylistic.

strategy: RollingUpdate with maxSurge: 1 and maxUnavailable: 0, so a rollout never drops below the replica count. Like the chart it ships with replicas: 1; raise it when you want rollout headroom and a survivable node failure. Every replica opens up to 10 pooled Postgres connections plus one for the fanout listener, so size max_connections for replicas × 11 — 12 per pod briefly at boot, while it holds the migration lock on its own connection. On a Postgres shared with other tenants that is a budget you are spending, not a number you get to set: check what is left before scaling up. Exhaustion does not present as a clean startup error — the pod comes up and serves, and what fails is the fanout listener’s reconnect, so votes stop crossing between replicas while each replica still looks healthy on its own. The plain manifest has no PodDisruptionBudget and no anti-affinity; the chart adds both.

The liveness probe hits /healthz, which never touches the database, because a DB blip must not restart the pod and drop every WebSocket.

Parley ships no database for Kubernetes. The prerequisites above apply identically here — PostgreSQL 13 or newer, a database and owning role you created, a verifying sslmode on the URI (or DATABASE_ALLOW_PLAINTEXT if you have already accepted plaintext), and one secret key holding the whole URI:

Terminal window
kubectl create secret generic parley \
--from-literal=database-url='postgres://parley:secret@host:5432/parley?sslmode=verify-full&sslrootcert=/etc/parley/ca/ca.pem'
kubectl apply -f deploy/k8s/deployment.yaml

sslrootcert=/etc/parley/ca/ca.pem is a path inside the container. The plain manifest mounts no CA; add the ConfigMap volume from Air-gapped or the handshake fails. require is still a legal boot mode if you have not mounted a CA yet.

With more than one replica the rollout is seamless. At replicaCount: 1 there is a reconnect as the pod is replaced, covered by the client’s banner.

The chart renders a PodDisruptionBudget with minAvailable: 1, but only above one replica: a budget in front of a single pod blocks every voluntary eviction and hangs the kubectl drain it was meant to survive. It also sets a topology spread constraint on kubernetes.io/hostname so replicas do not all land on one node. That one is ScheduleAnyway, so a single-node cluster still schedules instead of leaving pods Pending — tighten it to DoNotSchedule once you have nodes to spare.

The constraint carries matchLabelKeys: [pod-template-hash], which confines the skew calculation to one revision. It is there because of a failure seen on a real upgrade: without it, a rollout counts the outgoing pods as well, so with maxSurge the scheduler can satisfy the constraint by putting both new pods on one node. A soft constraint is never re-evaluated once the pods are running, so the install stays collapsed onto a single node afterwards — with two replicas, a satisfied PodDisruptionBudget, and one node failure between it and a total outage.

It reduces the problem rather than removing it. The constraint is still ScheduleAnyway, which is a preference: the scheduler weighs it against resource pressure, affinity and everything else, and may still stack the pods if those outweigh it. DoNotSchedule is the guarantee, at the cost of leaving pods Pending forever on a cluster without the nodes to satisfy it.

So after any upgrade of a multi-replica install, check where the pods actually are. This is the real safety net, not a formality:

Terminal window
kubectl --namespace parley get pods -o wide

Two different NODE values is the answer you want. If they have collapsed onto one node, deleting one pod reschedules it. On Kubernetes older than 1.27 the field is pruned by the API server without an error, so the check matters more there, not less.

The new pod runs migrations before the old pods are gone, so during a rollout the old version is serving against a newer schema. Parley’s migrations are additive and the old pods keep working; an old pod that restarts in that window starts normally and is replaced moments later. Roll forward — rolling back onto an image older than the migrations that have run is still refused, by design.

The safe default is TRUST_PROXY_HEADERS=false. After installing an Ingress, set it to true and add TRUSTED_PROXY_CIDRS for every Ingress/load-balancer network Parley can see as a trusted hop. Startup refuses proxy trust without the allowlist, and refuses 0.0.0.0/0 or ::/0 in it. See Configuration.

A proxy that appends to X-Forwarded-For rather than overwriting it is fine: Parley walks the chain right-to-left to the first untrusted hop and never reads the leftmost value. What has to be right is the allowlist.

The pod CIDR is a client-reachable network

Section titled “The pod CIDR is a client-reachable network”

Read this before turning proxy trust on in a cluster.

The immediate peer Parley sees is the ingress controller’s pod IP, and pod IPs are reassigned on every reschedule. Listing individual addresses breaks the next time the controller restarts, so in practice the only trustedProxyCIDRs that keeps working is the pod CIDR itself — 10.42.0.0/16 on k3s, 10.244.0.0/16 on kubeadm with flannel, whatever your CNI hands out.

That network is reachable from every workload in the cluster. Any pod can connect straight to Parley’s Service, send its own X-Forwarded-For, and pick whatever client address it likes — taking over another client’s bucket in the room-code passcode throttle, or in the open-mode identity-creation quota. The setting meant to close that hole has quietly reopened it, and the guard that only checks the allowlist is non-empty will not say a word.

So the pod CIDR is only safe alongside something that stops other pods reaching the Service. The chart ships a NetworkPolicy that default-denies ingress to Parley’s pods and admits only the ingress controller:

Terminal window
helm upgrade parley oci://ghcr.io/lets-parley/charts/parley --reuse-values \
--set networkPolicy.enabled=true \
--set networkPolicy.ingressController.namespace=ingress-nginx \
--set networkPolicy.ingressController.podSelector."app\.kubernetes\.io/name"=ingress-nginx

If you are replacing a hand-rolled policy with this one, add it before you remove yours. Two overlapping allow-policies are harmless — NetworkPolicies are additive, so the union of them is what applies. Zero policies is not harmless, and that is what you get for as long as the two halves of the swap are out of step. Under GitOps they are not applied together at all: removing your policy from Git and bumping the chart version are two different sources, and an operator who merged both in one PR measured 5 minutes 10 seconds with no policy in the cluster, confirmed by a probe from another namespace that returned 200 during the window. Adopt the chart’s policy first, confirm it is enforcing, then delete your own.

It is off by default, deliberately. A NetworkPolicy in a cluster whose CNI does not enforce them silently does nothing — the wrong thing to be on by default, because it looks like protection. And a policy whose selectors do not match your controller black-holes every request, which is the wrong thing to happen during someone else’s upgrade. Turn it on knowingly, then confirm the app is still reachable.

With Traefik as shipped by k3s the controller lives elsewhere:

networkPolicy:
enabled: true
ingressController:
namespace: kube-system
podSelector:
app.kubernetes.io/name: traefik

The namespace is matched on the kubernetes.io/metadata.name label, which the API server sets on every namespace from 1.21 onwards. Both selectors sit in one from element, so they are ANDed: pods with that label, in that namespace.

  • hostNetwork pods. They use the node’s address rather than a pod IP, so no namespaceSelector or podSelector matches them. If your ingress controller runs with hostNetwork: true, this policy is not the right tool and you need the node addresses allowed another way.
  • Node IPs that fall inside a trusted CIDR. Anything sourced from a node — a kubelet probe, a hostNetwork workload — arrives from the node address. If that address is inside trustedProxyCIDRs, its forwarded header is trusted no matter what the policy says. Check that your node CIDR and pod CIDR do not overlap.

Narrow the ingress controller’s own trust as well

Section titled “Narrow the ingress controller’s own trust as well”

trustedProxyCIDRs closes the hole at Parley’s boundary only. If your ingress controller accepts X-Forwarded-For from anyone and passes it on, the same forgery just moves one hop upstream and reaches Parley with the controller’s blessing. Set:

  • TraefikentryPoints.web.forwardedHeaders.trustedIPs, listing only the load balancer or CDN in front of it.
  • ingress-nginxproxy-real-ip-cidr in the ConfigMap, same list. Leave use-forwarded-headers however you like; Parley reads the chain right-to-left and an appended hop is not a problem.

Egress is off by default too, for the same reason

Section titled “Egress is off by default too, for the same reason”

The NetworkPolicy above is ingress-only. Parley makes exactly three kinds of outbound connection — DNS, Postgres, and (with auth.mode: oidc) the identity provider — and a default-deny egress policy written without knowing all three breaks the app: DNS failures look like a hung request, and a blocked Postgres or IdP connection looks like an outage.

A cluster that requires default-deny egress (a CUI boundary, an air-gapped environment) still needs a real policy, so networkPolicy.egress builds one from exactly the peers Parley talks to, rather than asking you to hand-write one and get DNS wrong:

Terminal window
helm upgrade parley oci://ghcr.io/lets-parley/charts/parley --reuse-values \
--set networkPolicy.enabled=true \
--set networkPolicy.egress.enabled=true \
--set networkPolicy.egress.postgres.cidr=10.0.1.5/32 \
--set networkPolicy.egress.postgres.port=5432 \
--set networkPolicy.egress.idp.cidr=10.0.2.5/32 \
--set networkPolicy.egress.idp.port=443

DNS to kube-system (UDP and TCP, port 53) is always included once egress is enabled — nothing else on the cluster resolves. The Postgres peer is required: the chart refuses to render networkPolicy.egress.enabled: true with no networkPolicy.egress.postgres.cidr, because a pod that cannot reach its own database is worse than no policy at all. The IdP peer is optional — leave it unset when auth.mode is not oidc, or before you know the provider’s address — and is simply left out of the rendered policy rather than rendered as an empty, match-nothing rule. networkPolicy.egress.extraEgress takes any further NetworkPolicyEgressRule verbatim, the same escape hatch as extraIngress.

A default route (0.0.0.0/0, ::/0) on postgres.cidr or idp.cidr looks scoped but defeats the whole point — it lets the pod reach every address on the internet on that one port — so the chart refuses to render it, the same refusal trustedProxyCIDRs gets.

Off by default for the same two reasons as the ingress half: a CNI that ignores NetworkPolicies would give a false sense of safety, and CI already asserts the render and the refusal above — see below.