Skip to content
PokerEnginePro
Cloud native · Helm · KEDA · OpenTofu

One chart.
Any Kubernetes, any cloud, your hardware.

The whole platform installs as one Helm release — every workload, autoscaler, disruption budget and network policy — on EKS, on AKS or on a rack of your own machines, and upgrades while the tables keep dealing.

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.

Every workload, probe, autoscaler, disruption budget and network policy in one chart, with ready values for ingress-nginx, AWS ALB, Azure Application Gateway and the Gateway API, and a values file for each in-jurisdiction deployment.

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.

install
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.
From the operations guide (install, step 5) — the same chart through OpenTofu takes the same files.

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.

AWS, Azure, a budget cloud or bare metal — the same release, step-by-step guides for each. Only the wire protocols are used — PostgreSQL, Redis, Kafka, OpenID Connect, Prometheus — so a managed service is a swap of endpoints, not of code.
What the platform runs on, on each target
NeedsAWSAzureYour hardware
KubernetesAmazon EKSAzure Kubernetes Servicek3s, RKE2 or Talos
PostgreSQLRDS for PostgreSQL or AuroraFlexible ServerCloudNativePG or Patroni
CacheElastiCache for ValkeyAzure Managed Redis or Azure Cache for RedisValkey or Redis
Event busAmazon MSK — SCRAM or IAMEvent Hubs, Kafka endpointRedpanda or Kafka
MetricsAmazon Managed Prometheus, with KEDAAzure Monitor managed Prometheus, with KEDAkube-prometheus-stack
Front doorApplication Load BalancerApplication Gatewayingress-nginx or a Gateway API implementation
AWS · Amazon MSK with IAM
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
From the operations guide (broker list shortened). No password anywhere: the silos sign in with their pods' IAM role.
Azure · KEDA on Azure Monitor
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
From the operations guide (a comment shortened). The same block with provider aws signs in to Amazon Managed Prometheus.

Managed services welcome

RDS or Flexible Server, ElastiCache or Azure Cache, MSK with IAM or Event Hubs, Amazon Managed Prometheus or Azure Monitor.

Spread across zones

Every tier spreads across availability zones and node pools of its own, so losing a zone costs a share of the tables, not all of them.

The front door

Every common ingress.
Sticky where SignalR needs it.

A SignalR client first calls 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.
Why SignalR needs sticky sessionsWith session affinity, the player's negotiate request and the WebSocket that follows both reach gateway A, and the connection opens. Without it, the negotiate reaches gateway A but the WebSocket lands on gateway B, which does not know the connection id, and the connection fails.PlayerLoadbalancerGatewayAGatewayBWITH AFFINITY1 · POST /hubs/table/negotiateconnection id + cookie poker-gw2 · WebSocket, with the cookieconnectedWITHOUT1 · negotiateconnection id2 · WebSocket“No Connection with that ID”

Measured

0 / 40

failed connects with session affinity

17 to 30 of 40 failed through the same ingress without it

values-nginx.yaml · ingress.annotations
# 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"
From packaging/helm/pokerenginepro/values-nginx.yaml (two lines left out, comments rewrapped). The ALB and Application Gateway files carry the same three things in their own annotations.
  • 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

Ready values for ingress-nginx, the AWS Load Balancer Controller, Azure's Application Gateway Ingress Controller and Gateway API routes — timeouts, health checks and session affinity included.

Sticky sessions where SignalR needs them

Session affinity on the players' gateway backend and nowhere else, so negotiate and the WebSocket that follows always reach the same gateway.

Identity and lockdown

Your provider. Your CA.
Nothing loosened.

Trust an in-house identity provider's private CA without relaxing anything, and refuse a revoked certificate — strict by default.
StrictThe default. A revoked certificate, or one whose status cannot be learned, is refused.
RevokedOnlyFor a private CA that publishes no revocation list: revoked is refused, unknown is logged.
OffNot checked — and the gateways say so at startup.

auth.certificateRevocation and staffAuth.certificateRevocation in values.yaml

values · an in-house identity provider
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
From the operations guide (comments shortened). RevokedOnly is only for a CA that publishes no CRL or OCSP responder — the default stays Strict.

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.

Rolling deploys drain a server before it stops; versioned grain interfaces keep old and new nodes in one cluster.
  1. 01

    Readiness first

    A silo told to stop answers 503 on readiness and leaves rotation before anything else happens.

  2. 02

    Hands finish

    It starts no new hand and lets the running ones play out — up to two minutes, a chart value.

  3. 03

    Kubernetes waits

    The termination grace period is the drain timeout plus thirty seconds, so no promised hand is cut short.

  4. 04

    One pod at a time

    Rollouts never drop below the running count, and a disruption budget keeps the tier above one node.

  5. 05

    Old and new together

    Every grain interface is versioned and backward compatible, so both releases share one cluster mid-rollout.

  6. 06

    The count stays

    The gateway count the autoscaler chose survives the upgrade; nothing resets to the chart's default.

templates/silo.yaml
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) }}
Excerpts from packaging/helm/pokerenginepro/templates/silo.yaml (comments rewrapped).

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.

A real cluster with real sign-in that proves autoscaling, failover, sticky sessions and network policies: 111 checks, zero restarts.

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

  1. 1The production shape: ready, unprivileged, spread across zones, routed, with real sign-in
  2. 2The web apps turned off and on again, one upgrade each way
  3. 3Sticky sessions, then the same load without the affinity
  4. 4The bus: Redpanda over SASL/SCRAM and TLS from a private CA, into ClickHouse
  5. 5Gateway API routes through Envoy Gateway
  6. 6Autoscaling, and upgrades that keep the autoscaler's count under Helm 4 and Helm 3
  7. 7KEDA, scaling out and back in
  8. 8NetworkPolicies refusing the wrong namespaces, ports and egress
  9. 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.

An OpenTofu module installs the release; cloud environments provision the network, cluster and managed data services around it.
infra/tofu/environments/own-hardware/main.tf
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"
  ]
}
Excerpt from infra/tofu/environments/own-hardware/main.tf (the data-service inputs left out, comments rewrapped). The module is the same on every target; only the inputs differ.

Six unprivileged images

Silo, gateway, admin API, archiver, player web and back-office web — each running as a non-root user with no capabilities.

Observability

Prometheus metrics on every node, OpenTelemetry traces end to end, structured logs, and Grafana dashboards and alert rules in the chart — including an alert when chip conservation fails in production. Runbooks set the service levels and recovery targets, and a scripted rehearsal proves recovery.

Signed releases

Every release ships signed images and a signed chart, a software bill of materials for each image, and a signed manifest and checksum list. Licensees check a release with a script. Upgrades are checked before they run, rehearsed in CI, and covered by a published versions and support policy.

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.