Running in production¶
Review each section below before directing production traffic to Coxswain.
Replicas and availability¶
Controller. The chart defaults to controller.replicas: 2 — two replicas with leader election. The PodDisruptionBudget (maxUnavailable: 1) is provisioned automatically when replicas >= 2, so a node drain never removes the last controller pod. The controller does not use an HPA (extra replicas are failover standby, not throughput).
On a leader failover (pod death, node drain, rolling upgrade) the warm standby takes the lease within one Lease TTL (15 s default) and immediately re-drives every reconciler — status writes, dedicated-proxy provisioning, and per-Gateway VIP provisioning all resume without waiting for their periodic backstops. The discovery stream is leader-only: proxies reconnect to the new leader (sub-second once the lease moves, via the leader-labelled coxswain-controller-discovery Service) and keep serving their last-good routing throughout — a failover stalls routing updates briefly, never live traffic. A single failed lease renewal (an API-server blip) does not demote a healthy leader; demotion requires either a positive takeover by another replica or elapsed time since the last successful renew approaching the TTL.
Shared proxy. The chart defaults to proxy.shared.replicas: 1, which is fine for evaluation but inadequate for production. Run at least two replicas — the PDB is only provisioned when the effective replica floor is ≥ 2:
# Static replicas:
helm upgrade coxswain oci://ghcr.io/coxswain-labs/charts/coxswain \
--namespace coxswain-system \
--set proxy.shared.replicas=2
# Or enable the HPA (minReplicas must also be ≥ 2 for the PDB to be active):
helm upgrade coxswain oci://ghcr.io/coxswain-labs/charts/coxswain \
--namespace coxswain-system \
--set proxy.shared.autoscaling.enabled=true \
--set proxy.shared.autoscaling.minReplicas=2 \
--set proxy.shared.autoscaling.maxReplicas=10 \
--set proxy.shared.autoscaling.targetCPUUtilizationPercentage=80
When the HPA is enabled, remove the static proxy.shared.replicas from your values — it is ignored while the HPA is active, and Helm will not fight the HPA on upgrades.
Verify both the HorizontalPodAutoscaler and PodDisruptionBudget are in place:
kubectl -n coxswain-system get hpa,pdb
Pod anti-affinity is not set by default. Add it via your values.yaml to spread shared proxy replicas across nodes:
proxy:
shared:
affinity:
podAntiAffinity:
preferredDuringSchedulingIgnoredDuringExecution:
- weight: 100
podAffinityTerm:
topologyKey: kubernetes.io/hostname
labelSelector:
matchLabels:
app.kubernetes.io/name: coxswain
app.kubernetes.io/component: shared-proxy
Resource requests and limits¶
The Helm chart defaults are sized for evaluation: requests 100m CPU / 128Mi memory, limits 500m CPU / 256Mi memory. Adjust for your expected traffic:
| Traffic level | CPU request | Memory request | Proxy threads |
|---|---|---|---|
| Light (< 1k rps) | 100m–250m | 128Mi | 2 |
| Medium (1k–10k rps) | 500m–1 | 128Mi–256Mi | 4 |
| Heavy (> 10k rps) | 2–4 | 256Mi–512Mi | ≥ CPU core count |
Set proxy.shared.threads to match the CPU cores allocated to the container:
helm upgrade coxswain oci://ghcr.io/coxswain-labs/charts/coxswain \
--namespace coxswain-system \
--set proxy.shared.resources.requests.cpu=500m \
--set proxy.shared.resources.requests.memory=128Mi \
--set proxy.shared.resources.limits.cpu=2 \
--set proxy.shared.resources.limits.memory=512Mi \
--set proxy.shared.threads=4
Health probes¶
The default Helm chart wires both probes automatically. Do not disable them:
- Liveness (
/healthz, port8081) — always 200 while the process is running. - Readiness (
/readyz, port8081) — returns 200 only once every subsystem in the pod reachesReadyorDegraded.
Subsystems have four states:
| State | Meaning | Effect on /readyz |
|---|---|---|
Pending |
Still initialising | 503 |
Ready |
Fully operational | passes |
Degraded |
Partial fault, still serving | passes |
Failed |
Fatal error | 503 |
Readiness gates differ by mode:
- Controller pod — all Kubernetes reflectors must complete their initial list and the controller must have acquired (or be in standby for) the Lease.
- Shared proxy and dedicated proxy pods — the discovery client must connect to the controller, bootstrap an SVID, and receive the first routing snapshot. Proxy pods do not participate in leader election; they will stay
NotReadyuntil the controller is reachable and the first snapshot lands.
Verify the probes are present on the deployed pod:
kubectl -n coxswain-system get deploy coxswain-shared-proxy \
-o jsonpath='{.spec.template.spec.containers[0].readinessProbe}'
If /readyz returns 503 on a running pod, inspect /api/v1/health to see which subsystem is blocked — see Troubleshooting.
Graceful shutdown¶
On SIGTERM, Coxswain drains in-flight connections for --proxy-shutdown-grace-period (default 30s), then forcibly closes any remaining connections after --proxy-shutdown-timeout (default 5s). Make sure the grace period aligns with your load balancer's connection draining timeout.
For long-lived connections (WebSocket, SSE), increase the grace period:
--proxy-shutdown-grace-period=60s
--proxy-shutdown-timeout=10s
Live Gateway listener changes¶
When a Gateway's listener set changes (port added or removed), Coxswain reconciles listeners in-process without restarting. New ports are bound immediately; removed ports enter a drain window (--proxy-listener-drain-timeout, default 30s) during which in-flight requests complete normally before the socket is released.
Tune the drain timeout to cover your longest expected upstream latency:
--proxy-listener-drain-timeout=60s
The coxswain_proxy_requests_force_closed_total{reason="drain_exceeded"} metric increments for any connection that could not finish within the window. It should remain at 0 under normal operation; a non-zero value means the drain timeout is too short for your workload.
Status address¶
Set --status-address to the external IP or hostname of your load balancer. Without it, Ingress.status and Gateway.status.addresses are left empty, which breaks cert-manager HTTP-01 challenges and external-dns.
--status-address=203.0.113.10
# or
--status-address=lb.example.com
TLS¶
TLS Secrets must be in the correct namespace — for Ingress, the same namespace as the Ingress object; for Gateway, the same namespace as the Gateway unless a ReferenceGrant permits cross-namespace access. See the TLS guide for cert-manager setup.
Observability¶
Configure a Prometheus scrape against the admin port (8082) — see the Observability reference for the PodMonitor and scrape_config examples. Alert on /readyz returning non-200 for more than one scrape interval.
The controller emits Kubernetes Warning Events on Ingress objects when a route conflict is detected (reason: RouteConflict) or an annotation value is invalid (reason: InvalidAnnotation). Set up an alerting rule or regularly query these events:
kubectl get events --field-selector reason=RouteConflict,type=Warning -A
kubectl get events --field-selector reason=InvalidAnnotation,type=Warning -A
See Troubleshooting — route conflicts and annotation diagnostics for remediation steps.
Set --log-format=json for structured log ingestion and --log=warn in production to reduce noise.
Annotation validation¶
On Kubernetes ≥ 1.30, the Helm chart installs a ValidatingAdmissionPolicy that rejects Ingress objects carrying malformed ingress.coxswain-labs.dev/* annotation values at apply time, surfacing errors immediately rather than silently falling back to defaults. It is enabled by default; disable with --set vap.enabled=false if your cluster does not support the admissionregistration.k8s.io/v1 API. See Helm install — ValidatingAdmissionPolicy.
Per-class configuration¶
CoxswainIngressClassParameters lets you set class-level defaults that apply to every Ingress claiming the class. Useful for cluster-wide policies:
spec.defaultAnnotations— defaultingress.coxswain-labs.dev/*values; per-Ingress annotations override these per key.spec.accessLog— setfalseto suppress proxy access-log lines for all routes in the class (never force-enables — afalseclass setting cannot be overridden per Ingress). See the Observability reference for the full access-log configuration model.
Security¶
Coxswain unconditionally strips Forwarded, X-Forwarded-For, X-Forwarded-Proto, and X-Real-IP from every upstream request before any route filter runs. The proxy owns these headers; client-supplied values are never forwarded. When PROXY protocol is enabled (via --ingress-accept-proxy-protocol for Ingress or a ClientTrafficPolicy for Gateway listeners), Coxswain injects a proxy-generated Forwarded header derived from the real PROXY-protocol client address.
RBAC¶
The default ClusterRole grants Coxswain cluster-wide:
- Read on
services,endpoints,endpointslices,secrets,configmaps(core API group). - Read on
ingresses,ingressclasses(networking.k8s.io). - Read on
gatewayclasses,gateways,httproutes,referencegrants,backendtlspolicies(gateway.networking.k8s.io). - Status writes (
*/status) oningresses,gateways,httproutes,backendtlspolicies, andgatewayclasses.
A separate namespaced Role (in coxswain-system) grants get, create, patch on coordination.k8s.io/leases — used only by the controller pod for leader election — plus get, list, patch on pods in the same namespace, used by the lease holder to maintain the discovery.coxswain-labs.dev/leader pod label that routes proxy discovery dials to the leader (and to strip a stale label off a crashed ex-leader). Review the rendered manifests with helm template or read deploy/manifests/controller-rbac.yaml before deploying. The shared proxy and dedicated proxy ServiceAccounts hold no Kubernetes RBAC — they are identity-only SAs whose projected tokens are used solely for mTLS bootstrap with the controller; verify with kubectl auth can-i --list --as=system:serviceaccount:coxswain-system:coxswain-shared-proxy (output should show only Kubernetes baseline grants).
If Coxswain should only manage resources in a single namespace, set watchNamespace. This restricts only the controller's reflectors; the proxy's discovery client is unaffected (it receives routing from the controller, not from K8s directly). The controller's ClusterRole/ClusterRoleBinding still ships cluster-wide; to scope it to a namespace, edit the rendered manifests by hand.
Signed image verification¶
Every release image is signed with cosign. Verify the signature before deploying to a production cluster:
cosign verify \
--certificate-identity-regexp \
"https://github.com/coxswain-labs/coxswain/.github/workflows/release.yml" \
--certificate-oidc-issuer "https://token.actions.githubusercontent.com" \
ghcr.io/coxswain-labs/coxswain:v0.5.0
See Verifying releases for the cosign verification flow for both the image and the Helm chart.