helm

Codex Pooler Helm Chart

Deploy Codex Pooler, a self-hosted gateway for sharing Codex account capacity across trusted agents and tools, on Kubernetes.

Website · Docs · Helm guide · GitHub · X · Reddit

Prerequisites

Install

helm repo add icoretech https://icoretech.github.io/helm
helm repo update
helm upgrade --install codex-pooler icoretech/codex-pooler \
  -n codex-pooler --create-namespace \
  --version 0.13.4 \
  --values values.production.yaml

OCI:

helm upgrade --install codex-pooler oci://ghcr.io/icoretech/charts/codex-pooler \
  -n codex-pooler --create-namespace \
  --version 0.13.4 \
  --values values.production.yaml

Required Secret

By default, the chart expects an existing Secret named codex-pooler-secrets:

secrets:
  create: false
  existingSecret: codex-pooler-secrets

The Secret must contain these keys:

Do not put upstream access tokens, API keys, cookies, auth.json, SMTP passwords, or raw client payloads in chart values.

Roles

A worker with a positive replica count requires an enabled local scheduler with a positive replica count. Workers execute queues but cannot lead Oban: without a scheduler, due scheduled jobs and retries remain unpromoted. The scheduler owns leader-gated staging and Cron, Lifeline and Pruner maintenance; it does not execute queues.

For a scheduler managed in another release, set oban.externalScheduler: true and disable or scale down this release’s scheduler explicitly. This acknowledges that an operational scheduler uses the same PostgreSQL database, schema and Oban instance name. The flag does not deploy a scheduler, change database configuration, disable the local scheduler, or verify that an external scheduler exists. Multiple schedulers sharing that Oban identity elect one database leader for staging and maintenance; separate Oban identities do not share that election.

App-only maintenance and releases with both Oban roles disabled or the worker scaled to zero remain valid. Those configurations do not execute background queues unless another release supplies workers.

The migration Job is part of the release, not a Helm hook, so it is visible in helm get manifest and removed by helm uninstall. Helm creates the Secret before the Job, and each release revision renders its own Job because a Job’s pod template is immutable.

Each app, worker and scheduler pod first runs a wait-for-schema init container using its own application image and database Secret. It starts only the repository dependencies, reads that image’s migration versions and waits until every version is present in the database. It neither starts the application nor creates tables or runs migrations: the migration Job remains the only migration writer. This orders startup on fresh installs and upgrades before heartbeat, settings, request handling or background jobs can touch an incomplete schema.

The startup wait also applies when migrations.enabled=false, so externally managed migrations must complete before the serving containers can start. Its outer process timeout uses migrations.activeDeadlineSeconds; the read-only loop leaves ten seconds for process startup and exit and bounds each query by the remaining monotonic deadline. A missing schema leaves the pod in initialization rather than starting an application that must crash and restart. A timeout reports schema_wait_timeout; inspect the migration Job and database connectivity instead of increasing readiness delays.

Images containing CodexPooler.Platform.JobRuntime, including the default 0.12.3, additionally recheck the required schema before each Oban start or restart. This remains a runtime safeguard if the schema becomes unavailable after initialization. Readiness probes report serving state separately:

helm test waits up to nine minutes for every migration to become applied and fails if any remain pending, on any image — run it after an install or upgrade if you want a single answer. helm --wait does not wait for Jobs unless you also pass --wait-for-jobs (spec.install.disableWaitForJobs: false and spec.upgrade.disableWaitForJobs: false in a Flux HelmRelease); use these wait settings to report migration failure as a failed release operation. Flux also requires waiting itself to remain enabled (disableWait: false). These Helm flags report completion; the pod init container supplies startup ordering.

helm rollback renders the target revision’s Job again, so it re-runs the migration with the rolled-back image. Ecto.Migrator applies only pending migrations and never reverses one, so that is a no-op when the schema is already ahead of the image.

Keep app.replicaCount at 1 unless app clustering is intentionally configured and verified. When app.replicaCount is >= 2, the chart requires app clustering and automatically enables websocket owner forwarding on app pods.

Chart 0.13.0 removes the inert app.websocketContinuity.allowUnsafeMultiReplica setting. Remove that field from existing values, even when it is false: schema validation now rejects it. It never bypassed the clustering requirements. Multiple app replicas still require clustering.enabled=true and clustering.participants.app=true, with owner forwarding enabled automatically. The chart defaults to application image 0.12.3; explicit older image overrides retain the startup limitations above.

Rolling Updates And Browser Affinity

Codex Pooler keeps its compiled browser assets inside the release image. No shared object storage or external asset synchronization is required.

With multiple app replicas, a rolling update temporarily serves both the old and new release. Phoenix uses revision-specific digested CSS and JavaScript paths, so a browser can receive HTML from one revision and then request its asset from a pod running the other revision. Ingress controllers that balance every HTTP request independently can return a transient asset 404 in that mixed-revision window.

The chart remains ingress-controller neutral:

Configure browser cookie affinity using the annotations supported by your ingress controller. For example, Traefik reads its cookie-affinity configuration from the backend Service:

app:
  service:
    annotations:
      traefik.ingress.kubernetes.io/service.sticky.cookie: "true"
      traefik.ingress.kubernetes.io/service.sticky.cookie.name: codex-pooler-affinity
      traefik.ingress.kubernetes.io/service.sticky.cookie.secure: "true"
      traefik.ingress.kubernetes.io/service.sticky.cookie.httponly: "true"
      traefik.ingress.kubernetes.io/service.sticky.cookie.samesite: lax

Other controllers may require affinity annotations on the Ingress instead; use ingress.annotations for those integrations. Kubernetes sessionAffinity: ClientIP can help clients that connect directly to the Service, but it is not equivalent to per-browser cookie affinity when an ingress proxy is the Service client:

app:
  service:
    sessionAffinity: ClientIP
    sessionAffinityConfig:
      clientIP:
        timeoutSeconds: 300

Do not shorten app.minReadySeconds, the websocket drain timeout, or the termination grace period to hide asset skew. Those settings protect in-flight websocket turns and spread reconnect load across the rollout; configure affinity at the HTTP routing layer instead.

Websocket Rollout Drain

The app Deployment keeps app.minReadySeconds: 120 by default. That delay spaces pod replacement so clients do not all reconnect at once; it is separate from the drain that starts when Kubernetes begins terminating an old pod.

At termination, the app first closes admission for new upstream dispatches, then shares the configured 85-second drain budget across every websocket activity still owned by that pod:

The drain waits for these activities together, not one class after another. New upstream dispatches are refused once admission closes. Work already admitted retains only the right to complete that exact execution; it does not authorize another request or transfer completion authority to a replacement owner.

Native compaction needs an application image that retains admitted collection through owner drain. For that execution, collecting a provider terminal and settling the request are separate from handing the result to the downstream client. The runtime waits within the existing budget for the matching request/attempt settlement and delivery receipt; provider completion or a successful database row alone does not establish client delivery. Cancellation, lost ownership, and the drain cutoff still prevent a late result from becoming a success. The chart hook cannot add this behavior to an older application image.

A turn still active at the deadline is finalized as owner_drained; clients should use their normal reconnect or retry path. The preStop hook writes the readiness-withdrawal marker before its drain RPC, then keeps the configured 10-second wait before SIGTERM. The app’s total termination grace period remains 120 seconds, with an 85-second drain setting; compaction does not extend those budgets. Owner drain and proxy drain are distinct: terminating only the proxy does not demonstrate completion during termination of the pod that owns the upstream compaction.

This guarantee is Kubernetes- and ingress-controller-neutral: it does not depend on a particular ingress annotation, shared asset store, or external rollout controller. It takes effect only after the rollout that introduces this behavior has replaced all older app pods, because a terminating pod running an earlier release cannot apply the new admission and drain protocol.

Browser affinity prevents cross-revision HTML and asset requests from bouncing between pods. It does not extend the websocket drain budget.

Monitoring

The app service can render a Prometheus Operator ServiceMonitor:

monitoring:
  serviceMonitor:
    enabled: true
    labels:
      release: kube-prometheus-stack
    interval: 10s
    scrapeTimeout: 5s
    path: /metrics

If metrics bearer authentication is enabled in Codex Pooler, keep the raw token in a Kubernetes Secret and reference it through monitoring.serviceMonitor.bearerTokenSecret.

Flux Example

apiVersion: source.toolkit.fluxcd.io/v1beta2
kind: HelmRepository
metadata:
  name: icoretech
  namespace: flux-system
spec:
  type: oci
  interval: 30m
  url: oci://ghcr.io/icoretech/charts
---
apiVersion: helm.toolkit.fluxcd.io/v2
kind: HelmRelease
metadata:
  name: codex-pooler
  namespace: codex-pooler
spec:
  interval: 5m
  install:
    disableWaitForJobs: false
  upgrade:
    disableWaitForJobs: false
  chart:
    spec:
      chart: codex-pooler
      version: "0.13.4"
      sourceRef:
        kind: HelmRepository
        name: icoretech
        namespace: flux-system
  values:
    config:
      host: codex-pooler.example.com
    ingress:
      enabled: true
      hosts:
        - host: codex-pooler.example.com
          paths:
            - path: /
              pathType: Prefix
    secrets:
      create: false
      existingSecret: codex-pooler-secrets

Configuration Reference

Values

Key Type Default Description
app.affinity object {}  
app.drainMarkerPath string "/tmp/codex-pooler-draining"  
app.enabled bool true  
app.lifecycle.preStop.drainTimeoutSeconds int 85 Maximum seconds to wait for the websocket rollout drain RPC before continuing pod shutdown. The drain returns as soon as in-flight turns finish, so the budget is only consumed while a long turn is actually running. Turns get about drainTimeoutSeconds minus 10.7 seconds; at 10 or less every drain cuts them at once. The default is sized so that window covers p99 in-flight turn durations instead of cutting at p95.
app.lifecycle.preStop.enabled bool true  
app.lifecycle.preStop.sleepSeconds int 10 Seconds to keep the pod unready after the drain marker/RPC before Kubernetes sends SIGTERM.
app.minReadySeconds int 120 Seconds a newly ready app pod must stay ready before the rollout continues. Spacing the per-pod websocket drains keeps reconnecting clients from re-uploading their sessions all at once after a deploy; set 0 to disable the pause.
app.nodeSelector object {}  
app.podAnnotations object {}  
app.podDisruptionBudget.enabled bool true  
app.podDisruptionBudget.minAvailable int 1  
app.replicaCount int 1  
app.resources.limits object {"memory":"2Gi"} No CPU limit by default: the BEAM gateway must burst during post-rollout reconnect storms (TLS + replay body decompression), and CFS throttling there surfaces as client-visible 408 body-read timeouts. Set a limit explicitly only if your platform requires one.
app.resources.limits.memory string "2Gi" Memory ceiling for the HTTP and long-lived WebSocket gateway process.
app.resources.requests.cpu string "100m"  
app.resources.requests.memory string "512Mi"  
app.service.annotations object {} Arbitrary annotations for the app Service. Use the annotations supported by your ingress or load-balancer controller when browser-level cookie affinity is required during mixed-revision rolling updates.
app.service.port int 4000  
app.service.sessionAffinity string "None" Kubernetes Service session affinity. ClientIP is useful for clients that reach the Service directly, but it is not a portable replacement for browser cookie affinity behind an ingress controller.
app.service.sessionAffinityConfig object {} Additional Kubernetes Service session-affinity settings. Set clientIP.timeoutSeconds only when sessionAffinity is ClientIP.
app.service.type string "ClusterIP"  
app.startupProbe.enabled bool true  
app.startupProbe.failureThreshold int 12  
app.startupProbe.initialDelaySeconds int 5  
app.startupProbe.path string "/healthz"  
app.startupProbe.periodSeconds int 5  
app.startupProbe.timeoutSeconds int 2  
app.strategy.maxSurge int 1  
app.strategy.maxUnavailable int 0  
app.terminationGracePeriodSeconds int 120 Total shutdown budget. Formula: max(drainTimeoutSeconds + 2 seconds RPC allowance + sleepSeconds, 2 seconds RPC allowance + sleepSeconds + drainTimeoutSeconds for the full in-VM fallback) + 10 seconds endpoint shutdown + 5 seconds margin.
app.tolerations list []  
clustering.cookie.existingSecret string ""  
clustering.cookie.existingSecretKey string "release-cookie"  
clustering.cookie.value string ""  
clustering.distributionPort string "9000"  
clustering.enabled bool false  
clustering.epmdPort string "4369"  
clustering.headlessService.enabled bool true  
clustering.headlessService.nameOverride string ""  
clustering.headlessService.publishNotReadyAddresses bool true  
clustering.participants.app bool true  
clustering.participants.scheduler bool true  
clustering.participants.worker bool true  
clustering.query string ""  
config.ectoIpv6 string "false"  
config.erlMaxPorts string "1048576" Bound ERTS port-table capacity independently of a container’s file-descriptor rlimit.
config.host string "codex-pooler.example.com"  
config.lang string "C.UTF-8"  
config.lcAll string "C.UTF-8"  
config.obanJobsQueueLimit string "8"  
config.obanShutdownGracePeriodMs string "55000"  
config.poolSize int 10  
config.port int 4000  
fullnameOverride string ""  
image.pullPolicy string "IfNotPresent"  
image.repository string "ghcr.io/icoretech/codex-pooler"  
image.tag string "" Image tag. Defaults to the chart appVersion when empty.
imagePullSecrets list []  
ingress.annotations object {}  
ingress.className string ""  
ingress.enabled bool false  
ingress.hosts[0].host string "codex-pooler.example.com"  
ingress.hosts[0].paths[0].path string "/"  
ingress.hosts[0].paths[0].pathType string "Prefix"  
ingress.tls list []  
migrations.activeDeadlineSeconds int 900 Wall-clock budget for the migration Job and each serving pod’s read-only schema startup wait (minimum 61s). The startup wait leaves 10s for process startup/exit. Releases supporting MIGRATION_ADVISORY_LOCK_WAIT_SECONDS receive a contention limit of min(600, deadline minus 60) seconds; older images ignore that setting.
migrations.backoffLimit int 3  
migrations.enabled bool true  
migrations.resources.limits.cpu string "250m"  
migrations.resources.limits.memory string "384Mi"  
migrations.resources.requests.cpu string "50m"  
migrations.resources.requests.memory string "192Mi"  
migrations.ttlSecondsAfterFinished int 300  
monitoring.serviceMonitor.annotations object {}  
monitoring.serviceMonitor.bearerTokenSecret.key string ""  
monitoring.serviceMonitor.bearerTokenSecret.name string ""  
monitoring.serviceMonitor.enabled bool false  
monitoring.serviceMonitor.interval string "30s"  
monitoring.serviceMonitor.labels object {}  
monitoring.serviceMonitor.metricRelabelings list []  
monitoring.serviceMonitor.path string "/metrics"  
monitoring.serviceMonitor.relabelings list []  
monitoring.serviceMonitor.scheme string "http"  
monitoring.serviceMonitor.scrapeTimeout string "10s"  
nameOverride string ""  
oban.externalScheduler bool false Acknowledge a separate scheduler on the same PostgreSQL database and schema. Does not deploy or configure it.
oban.scheduler.affinity object {}  
oban.scheduler.enabled bool true  
oban.scheduler.nodeSelector object {}  
oban.scheduler.podAnnotations object {}  
oban.scheduler.replicaCount int 1  
oban.scheduler.resources.limits.cpu string "500m"  
oban.scheduler.resources.limits.memory string "584Mi"  
oban.scheduler.resources.requests.cpu string "75m"  
oban.scheduler.resources.requests.memory string "584Mi"  
oban.scheduler.strategy.maxSurge int 1  
oban.scheduler.strategy.maxUnavailable int 0  
oban.scheduler.terminationGracePeriodSeconds int 75  
oban.scheduler.tolerations list []  
oban.worker.affinity object {}  
oban.worker.enabled bool true  
oban.worker.nodeSelector object {}  
oban.worker.podAnnotations object {}  
oban.worker.replicaCount int 1  
oban.worker.resources.limits.cpu string "500m"  
oban.worker.resources.limits.memory string "768Mi"  
oban.worker.resources.requests.cpu string "75m"  
oban.worker.resources.requests.memory string "768Mi"  
oban.worker.strategy.maxSurge int 1  
oban.worker.strategy.maxUnavailable int 0  
oban.worker.terminationGracePeriodSeconds int 75  
oban.worker.tolerations list []  
podAnnotations object {}  
secrets.create bool false  
secrets.databaseUrl string ""  
secrets.existingSecret string "codex-pooler-secrets"  
secrets.secretKeyBase string ""  
secrets.totpEncryptionKey string ""  
secrets.totpKeyVersion string "v1"  
secrets.upstreamSecretKey string ""  
secrets.upstreamSecretKeyVersion string "v1"  
serviceAccount.annotations object {}  
serviceAccount.create bool true  
serviceAccount.name string ""