Kubernetes
Before you install: the database
Section titled “Before you install: the database”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.pgcryptobecame a trusted extension in PostgreSQL 13, so any role withCREATEon the target database can install it there — no ownership and no superuser required. On 12 and below, installing it still needs superuser, and migration0001fails:FATAL: database migration failed. There is no ceiling — 14, 15, 16 and 17 are all fine. The compose file pinspostgres:16-alpinebecause 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_URLCREATEon 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/NOTIFYon two fixed channel names,parley_sessionandparley_revoke.pg_notifyis 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:
Migratetakes 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
sslmodeonDATABASE_URL. Parley refuses to start ifsslmodeis absent,disable,alloworpreferunlessDATABASE_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.requirestill boots (it encrypts but never checks the server’s certificate);verify-cachecks 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 ofpostgres not reachable yet, retrying— beforeFATAL: could not connect to Postgresand a restart. -
A quick sanity check from anywhere that can reach the database (set
PGPASSWORDin 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 pathpsql 'postgres://parley@host:5432/parley?sslmode=verify-full&sslrootcert=./ca.pem' \-c "select has_database_privilege('parley', 'parley', 'CREATE')" \-c 'select version()'A
tresult confirms the role can installpgcrypto. If that succeeds, Parley will migrate. The URI you store in the secret uses the in-pod path/etc/parley/ca/ca.pemafter the ConfigMap mount below.
The secrets Parley reads, exactly
Section titled “The secrets Parley reads, exactly”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.
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=oidckubectl 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.
External secret operators
Section titled “External secret operators”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:
kubectl --namespace parley describe secret parleydescribe 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:
kubectl --namespace parley rollout restart deploy/parleyUntil then the old value stays live, which usually means the rotation looks successful right up to the next pod restart.
Install the Helm chart
Section titled “Install the Helm chart”The chart is published to the same registry as the image. With the secret above in place:
helm install parley oci://ghcr.io/lets-parley/charts/parley --version 0.10.0 \ --set database.existingSecret=parley \ --set baseURL=https://parley.example.comhelm 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.
Upgrading an existing install
Section titled “Upgrading an existing install”Re-run helm upgrade with the new chart version and keep the values you set
last time:
helm upgrade parley oci://ghcr.io/lets-parley/charts/parley --version 0.10.0 \ --reuse-values
kubectl rollout status deploy/parleyhelm test parleycurl -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.
Pin --version
Section titled “Pin --version”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.
The values that matter
Section titled “The values that matter”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, underdatabase.secretKey(defaultdatabase-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’sappVersion, 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-fipsis refused the same waylatestis. See Cryptography.image.digest— empty keepsrepository:tag. Set it tosha256:…to renderrepository@digestand ignore the tag, the same waytests.image.digestpins thehelm testpod. See Air-gapped installs.ingress.enabled,ingress.className,ingress.host,ingress.tls— off by default. The chart ships theproxy-read-timeoutannotations WebSockets need for ingress-nginx; every other controller ignores them and needs its own setting. See below.trustProxyHeadersandtrustedProxyCIDRs— 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-CIDRtrustedProxyCIDRssafe. 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=trueserves/metricson the app port, unauthenticated. The Ingress path is/Prefix, so leaving it routed publishes the exposition. Admit the scraper’s pods withnetworkPolicy.extraIngressand keep the path off the public hostname; see Observability.auth.mode(openoroidc) andauth.oidc.*. Withoidcyou must set eitherauth.oidc.existingSecret(confidential client; the secret is read from a Secret you create, never from values) orauth.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.
TLS is entirely the ingress’s job
Section titled “TLS is entirely the ingress’s job”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-tlsSetting 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.
WebSocket timeouts are per-controller
Section titled “WebSocket timeouts are per-controller”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/v1kind: HelmChartConfigmetadata: name: traefik namespace: kube-systemspec: valuesContent: |- ports: websecure: transport: respondingTimeouts: readTimeout: 300s idleTimeout: 300sidleTimeout 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 tag —
latest,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.
replicaCountmust be a whole number:1.5would otherwise reach the API server asreplicas: 1.5and 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.0still renders — scaling to zero is how you take Parley down for a maintenance window. - A missing
database.existingSecret. - OIDC with neither
auth.oidc.existingSecretnorauth.oidc.publicClientset — or with both. A public client is supported; an unstated one is not, because a values merge that dropsexistingSecretwould 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 noname. See above. networkPolicy.egress.enabledwith nonetworkPolicy.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.
A plain manifest, if you do not run Helm
Section titled “A plain manifest, if you do not run Helm”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.
Bring your own Postgres
Section titled “Bring your own Postgres”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:
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.yamlsslrootcert=/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.
Rollouts
Section titled “Rollouts”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:
kubectl --namespace parley get pods -o wideTwo 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.
One thing to know about the window
Section titled “One thing to know about the window”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 manifest leaves proxy trust disabled
Section titled “The manifest leaves proxy trust disabled”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:
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-nginxIf 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: traefikThe 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.
What the NetworkPolicy does not cover
Section titled “What the NetworkPolicy does not cover”hostNetworkpods. They use the node’s address rather than a pod IP, so nonamespaceSelectororpodSelectormatches them. If your ingress controller runs withhostNetwork: 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:
- Traefik —
entryPoints.web.forwardedHeaders.trustedIPs, listing only the load balancer or CDN in front of it. - ingress-nginx —
proxy-real-ip-cidrin the ConfigMap, same list. Leaveuse-forwarded-headershowever 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:
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=443DNS 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.