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
secrets.create=true is used from a private values sourcehelm 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
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:
database-urlsecret-key-basetotp-encryption-keytotp-key-versionupstream-secret-keyupstream-secret-key-versionDo not put upstream access tokens, API keys, cookies, auth.json, SMTP passwords, or raw client payloads in chart values.
app serves HTTP with OBAN_MODE=web and PHX_SERVER=trueoban.worker runs background jobs with OBAN_MODE=workeroban.scheduler runs scheduled jobs with OBAN_MODE=schedulermigrations runs release migrations and imports the vendored pricing feedA 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:
CodexPooler.Release.readiness_check/0, including 0.11.5, verify every migration they carry. Older images use a fallback requiring one applied schema_migrations row. The init container supplies the complete startup check even for those older images./readyz, which reports database connectivity on images up to 0.7.8 and the applied schema on newer ones. It runs only after the startup schema wait succeeds.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.
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:
app.service.annotations passes arbitrary annotations to the app Serviceingress.annotations passes arbitrary annotations to the Ingressapp.service.sessionAffinity and app.service.sessionAffinityConfig expose the standard Kubernetes Service affinity fieldsConfigure 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.
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.
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.
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
| 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 | "" |