One chart.
Any Kubernetes, any cloud, your hardware.
Measured
111 / 111
checks on a production‑shaped cluster
real sign‑in, autoscaling, failover, network policies — zero pod restarts
Measured
0 / 40
failed connects with session affinity
17 to 30 of 40 failed through the same ingress without it
Measured
2 → 4
gateways, scaled on connections
through the HPA and through KEDA, and back to 2 when the load left
Measured
0
pod restarts across the whole lab
upgrades, scale‑outs and NetworkPolicies included
One Helm chart
The whole platform.
One release.
Nothing in the chart is cloud-specific. It needs a container runtime, a PostgreSQL wire endpoint and an ingress that can carry WebSockets; everything a cloud adds — a load balancer controller, a managed Prometheus, IAM for the event bus — is a values file on top.
aws eks update-kubeconfig --region eu-west-1 --name poker-prod
helm upgrade --install poker packaging/helm/pokerenginepro \
--namespace poker --create-namespace \
-f packaging/helm/pokerenginepro/values-aws-alb.yaml \
-f values-aws-prod.yaml \
--wait --timeout 15m
# The chart's own values.yaml is always the base; later -f files win.
01
Workloads
Gateways, silos, the admin API, the archiver and both web apps — six Deployments from six images, each with its own probes, resources and node pool.
- Deployment
- Service
02
Scaling
The gateways' autoscaler on live connections: the chart's own HPA, or a KEDA ScaledObject that signs in to a managed Prometheus. A PodMonitor feeds either.
- HorizontalPodAutoscaler
- ScaledObject
- TriggerAuthentication
- PodMonitor
03
Resilience
A disruption budget that keeps tables somewhere, one-at-a-time rollouts, drain before stop, and every tier spread across zones.
- PodDisruptionBudget
- topologySpreadConstraints
04
Routing
An Ingress per hostname or Gateway API routes — players and staff on hostnames of their own, the web apps on “/” beside the API's paths.
- Ingress
- HTTPRoute
05
Security
A NetworkPolicy per workload in both directions, service accounts for cloud IAM, restricted pod security, and credentials only through secret references.
- NetworkPolicy
- ServiceAccount
- secretKeyRef
06
Guard rails
The render refuses unsafe combinations — a SASL protocol without a secret, a CA without an https:// authority, KEDA on a managed Prometheus over plain HTTP — and warns when the players' ingress has no affinity.
- helm template
- NOTES.txt
Any cloud
AWS, Azure or your own racks.
The same release on each.
| Needs | AWS | Azure | Your hardware |
|---|---|---|---|
| Kubernetes | Amazon EKS | Azure Kubernetes Service | k3s, RKE2 or Talos |
| PostgreSQL | RDS for PostgreSQL or Aurora | Flexible Server | CloudNativePG or Patroni |
| Cache | ElastiCache for Valkey | Azure Managed Redis or Azure Cache for Redis | Valkey or Redis |
| Event bus | Amazon MSK — SCRAM or IAM | Event Hubs, Kafka endpoint | Redpanda or Kafka |
| Metrics | Amazon Managed Prometheus, with KEDA | Azure Monitor managed Prometheus, with KEDA | kube-prometheus-stack |
| Front door | Application Load Balancer | Application Gateway | ingress-nginx or a Gateway API implementation |
bus:
bootstrapServers: "b-1.poker.xxxxxx.c2.kafka.eu-west-1.amazonaws.com:9098,…"
securityProtocol: SaslSsl # IAM is served only over TLS
saslMechanism: AwsMskIam
awsRegion: eu-west-1 # the cluster's region, required
silo:
serviceAccount:
create: true
annotations: # IRSA only; leave out with Pod Identity
eks.amazonaws.com/role-arn: arn:aws:iam::123456789012:role/poker-silo
gateway:
autoscaling:
enabled: true # stays true
provider: keda
minReplicas: 2
maxReplicas: 20
targetConnectionsPerPod: 8000
keda:
serverAddress: https://poker-ws-abcd.westeurope.prometheus.monitor.azure.com
authentication:
provider: azure-workload
Managed services welcome
Spread across zones
The front door
Every common ingress.
Sticky where SignalR needs it.
negotiate, then opens a WebSocket with the connection id it was given. The WebSocket must reach the gateway that answered, or it fails. So the players' front door carries session affinity — on the gateway backend and nowhere else — and timeouts long enough for a session that lasts hours.Measured
0 / 40
failed connects with session affinity
17 to 30 of 40 failed through the same ingress without it
# WebSockets live for hours; nginx closes a proxied
# connection that is silent for 60 s by default.
nginx.ingress.kubernetes.io/proxy-read-timeout: "3600"
nginx.ingress.kubernetes.io/proxy-send-timeout: "3600"
# Sticky sessions: SignalR's negotiate request and the
# WebSocket that follows must reach the same gateway.
# A cookie rather than an IP hash, so players behind one
# carrier NAT still spread out.
nginx.ingress.kubernetes.io/affinity: cookie
nginx.ingress.kubernetes.io/affinity-mode: balanced
nginx.ingress.kubernetes.io/session-cookie-name: poker-gw
nginx.ingress.kubernetes.io/session-cookie-max-age: "86400"
# A draining gateway goes unready first; a request that
# still fails there re-pins to a healthy one.
nginx.ingress.kubernetes.io/session-cookie-change-on-failure: "true"
ingress-nginx
values-nginx.yaml
- Affinity cookie poker-gw, balanced mode
- Re-pins when a gateway fails
- 3,600 s read and send timeouts
AWS ALB
values-aws-alb.yaml
- lb_cookie stickiness, pod IPs as targets
- Health check on /health/ready
- 3,600 s idle timeout, 45 s deregistration
Azure Application Gateway
values-azure-appgw.yaml
- Cookie-based affinity
- Connection draining, 45 s
- 3,600 s request timeout
Gateway API
gatewayApi in values.yaml
- HTTPRoutes on a Gateway you run
- Cookie session persistence on the gateway rule
- Or the implementation's own affinity policy
Every common front door
Sticky sessions where SignalR needs them
Identity and lockdown
Your provider. Your CA.
Nothing loosened.
auth.certificateRevocation and staffAuth.certificateRevocation in values.yaml
auth:
authority: https://id.example.internal/realms/poker
ca: { secretName: poker-idp-ca } # a Secret; key defaults to ca.crt
certificateRevocation: RevokedOnly
staffAuth:
authority: https://id.example.internal/realms/poker-staff
ca: { secretName: poker-idp-ca }
certificateRevocation: RevokedOnly
Locked-down workloads
- Silos accept cluster traffic from silos, gateways and the admin API only.
- HTTP reaches a workload only from the ingress controller and monitoring.
- Egress is DNS plus each tier's own services — the database, the cache, the brokers, the identity provider.
- Every pod runs as a non-root user, with no capabilities and the default seccomp profile.
- Credentials only ever come from Kubernetes Secrets, never from values.
Upgrades during play
Ship a release.
Not a maintenance window.
01
Readiness first
A silo told to stop answers 503 on readiness and leaves rotation before anything else happens.
02
Hands finish
It starts no new hand and lets the running ones play out — up to two minutes, a chart value.
03
Kubernetes waits
The termination grace period is the drain timeout plus thirty seconds, so no promised hand is cut short.
04
One pod at a time
Rollouts never drop below the running count, and a disruption budget keeps the tier above one node.
05
Old and new together
Every grain interface is versioned and backward compatible, so both releases share one cluster mid-rollout.
06
The count stays
The gateway count the autoscaler chose survives the upgrade; nothing resets to the chart's default.
strategy:
type: RollingUpdate
rollingUpdate:
# One at a time, and never below the running count:
# tables move to the pods that stay.
maxUnavailable: 0
maxSurge: 1
# …
# Longer than the drain timeout, or Kubernetes would kill
# hands the platform promised to finish.
terminationGracePeriodSeconds: {{ add .Values.silo.drainTimeoutSeconds 30 }}
# …
kind: PodDisruptionBudget
spec:
# A voluntary disruption may never take the tier below
# this: tables have to live somewhere.
minAvailable: {{ max 1 (sub .Values.silo.replicas 1) }}
Upgrades keep the autoscaler's count
An upgrade renders the gateway count the autoscaler chose, so a release never resets or drops a scaled-out tier.
Migrations one silo at a time
A silo migrates the database before it answers its probes, under an advisory lock, with the startup allowance a chart value.
Proven, not promised
A real cluster, real sign-in.
Eight gates on every push.
Measured
111 / 111
checks on a production‑shaped cluster
real sign‑in, autoscaling, failover, network policies — zero pod restarts
The lab's phases, in order
- 1The production shape: ready, unprivileged, spread across zones, routed, with real sign-in
- 2The web apps turned off and on again, one upgrade each way
- 3Sticky sessions, then the same load without the affinity
- 4The bus: Redpanda over SASL/SCRAM and TLS from a private CA, into ClickHouse
- 5Gateway API routes through Envoy Gateway
- 6Autoscaling, and upgrades that keep the autoscaler's count under Helm 4 and Helm 3
- 7KEDA, scaling out and back in
- 8NetworkPolicies refusing the wrong namespaces, ports and egress
- 9Nothing reached a development endpoint
k3s across two zones with NetworkPolicies enforced, Keycloak over HTTPS from a lab CA, ingress-nginx, Envoy Gateway, kube-prometheus-stack and KEDA — the release in Production, changed only through the chart.
Eight CI gates on every push
Build and test on PostgreSQL, Valkey and Keycloak, a 100,000-hand soak, both web apps, a failover cluster, the chart and the images.
build-and-test
The .NET suites against PostgreSQL 16, Valkey 8 and Keycloak realms.
bot-runner
A 100,000-hand soak with chips conserved.
web-client
Vitest, the build, and Playwright against a real silo.
back-office
OpenAPI types checked, Vitest, Playwright against silo and admin API.
cluster
Two silos and two gateways, and the failover tests.
infrastructure
tofu fmt, validate and test.
chart
helm lint --strict, every values file rendered, kubeconform, the render checks.
images
All six images built.
The build pipeline, as it runs on every change
Day two
Infrastructure as code.
Eyes on everything.
module "poker" {
source = "../../modules/pokerenginepro"
namespace = "poker-certification"
image_tag = var.image_tag
ingress_host = var.ingress_host
# The sizing under test, from the capacity model:
# 6 gateways at ~10k connections each (5 + 1 so a node
# can be killed mid-run), 8 silos for 10k room grains
# and 50k session grains.
gateway_replicas = 6
silo_replicas = 8
values_files = [
"../../../../packaging/helm/pokerenginepro/values-certification.yaml"
]
}
Six unprivileged images
Observability
Signed releases
Install it on your cluster.
Or watch us install it on ours.
A walkthrough of the chart, the lab and an upgrade during play — on the cloud you run, or on your own hardware.