Deploy with Helm
The chart in deploy/helm/polo deploys the API, the outbox relay, the scheduler and the runtime worker as Deployments, the database migration as a pre-install and pre-upgrade Job, a Service, an Ingress, optional watch processes and an optional autoscaler for the API. Read the process model, images and secrets in Deploy with Docker Compose first; they apply here unchanged.
Backing services
Section titled “Backing services”PostgreSQL with pgvector, Redis, Temporal and S3-compatible storage are external to the polo chart and referenced by URL. You have two options:
- Managed or existing services. Point the Secret and values at them.
- In-cluster services. The second chart,
deploy/helm/polo-dependencies, runs PostgreSQL with pgvector, Redis and Temporal as single replicas on persistent volumes, with an optional daily database dump to S3 (backups.enabled). Install it as its own release beforepolo, because the migration hook needs PostgreSQL. It readsPOLO_DATABASE_PASSWORDandPOLO_REDIS_PASSWORDfrom one existing Secret (existingSecret, defaultpolo-dependencies). With the release namedpolo-dependencies, the services arepolo-dependencies-postgres:5432,polo-dependencies-redis:6379andpolo-dependencies-temporal:7233.
Install
Section titled “Install”-
Create the credentials Secret in the release namespace. The keys are the environment variable names the processes read.
Terminal window kubectl -n polo create secret generic polo-secrets \--from-literal=POLO_API_DATABASE_URL='postgresql://polo_application:...@postgres:5432/polo_product' \--from-literal=POLO_API_MIGRATION_DATABASE_URL='postgresql://polo_migration:...@postgres:5432/polo_product' \--from-literal=POLO_API_DISPATCH_DATABASE_URL='postgresql://polo_dispatch:...@postgres:5432/polo_product' \--from-literal=POLO_API_DATABASE_PASSWORD='...' --from-literal=POLO_API_DISPATCH_PASSWORD='...' \--from-literal=POLO_API_REDIS_URL='redis://:password@redis:6379/0' \--from-literal=POLO_API_WORKER_KEYS='{"worker":"...","scheduler":"..."}' \--from-literal=POLO_API_OPERATION_KEY='...' --from-literal=POLO_API_SSO_KEY='...' \--from-literal=POLO_API_ARTIFACT_ACCESS_KEY='...' --from-literal=POLO_API_ARTIFACT_SECRET_KEY='...' \--from-literal=POLO_RUNTIME_WORKER_TOKEN='...' --from-literal=POLO_SCHEDULER_WORKER_TOKEN='...'The ids in
POLO_API_WORKER_KEYSmust matchworkerIds.runtimeandworkerIds.scheduler(defaultsworkerandscheduler) and theworkerIdof each enabled watcher. Withmetrics.enabled: truethe Secret also needsPOLO_API_METRICS_TOKEN. -
Create the model registry and model credentials Secrets. The registry is the same JSON as the local launcher’s
models.json; the credentials Secret has one key perapi_key_envnamed in the registry.Terminal window kubectl -n polo create secret generic polo-models --from-file=models.json=./models.jsonkubectl -n polo create secret generic polo-model-credentials --from-literal=OPENAI_API_KEY='...' -
Write a values file.
publicUrl: https://<your-domain>image:api: { repository: <your-registry>/polo-api, tag: "<tag>" }runtime: { repository: <your-registry>/polo-runtime, tag: "<tag>" }temporal: { address: temporal-frontend.temporal.svc.cluster.local:7233, namespace: polo }artifacts: { bucket: polo-workspace-artifacts, region: eu-west-1 } # endpoint: https://... for non-AWS storeshostedModelRoutes:- { id: openai-default, name: OpenAI default, model: gpt-4.1, shared: true, tenantIds: [] }api:trustedProxies: ["10.244.0.0/16"] # where the Ingress controller connects fromworker:models: { existingSecret: polo-models }modelCredentialsSecret: polo-model-credentialssnapshots: { existingClaim: polo-snapshots }ingress: { className: nginx, tls: { secretName: polo-tls } } -
Install or upgrade. The migrate Job runs first as a hook; the Deployments roll out after it succeeds.
Terminal window helm upgrade --install polo deploy/helm/polo -n polo -f values.yamlkubectl -n polo rollout status deployment/polo-api
To lint the chart locally, pass values, because publicUrl is required:
helm lint deploy/helm/polo -f deploy/helm/polo/ci/lint-values.yamlValues you will most likely set
Section titled “Values you will most likely set”| Value | Purpose |
|---|---|
publicUrl |
The HTTPS origin users open. Required. |
secrets.existingSecret |
The credentials Secret. Default polo-secrets. |
api.trustedProxies |
Addresses or CIDR blocks whose X-Forwarded-For the API believes. Required while the Ingress is enabled. |
api.replicas, api.autoscaling.*, api.podDisruptionBudget.* |
API scale. Default two replicas, autoscaling off. |
temporal.address, temporal.namespace, temporal.taskQueue, temporal.tls, temporal.apiKey |
The Temporal frontend. With apiKey: true the Secret also holds POLO_API_TEMPORAL_API_KEY and POLO_TEMPORAL_API_KEY. |
artifacts.bucket, artifacts.region, artifacts.endpoint |
Artifact storage. An empty endpoint means AWS S3 in region. |
tls.caSecret |
A Secret with a ca.crt the API trusts in addition to the system roots, for internal HTTPS endpoints with a private CA. |
hostedModelRoutes |
The model routes workspaces may choose; each mirrors a route of the worker’s registry. |
worker.models.existingSecret, worker.modelCredentialsSecret |
The model registry and its credentials. |
worker.snapshots.existingClaim |
A PersistentVolumeClaim for workspace snapshots; an emptyDir when empty. |
scheduler.apiUrl, worker.apiUrl |
The API origin for these processes. Defaults to publicUrl; must be HTTPS. |
operationAllowedHosts, uptimeAllowedHosts |
Optional comma-separated host allow-lists for registered endpoints and uptime probes. |
watchers.<name>.enabled |
Optional watch processes: calendar, gmailIntake, managedGmail, sourceFollowthrough. |
metrics.enabled, metrics.serviceMonitor.enabled |
Prometheus metrics; see Operate. |
defaultRunner.*, publicRunnerImage |
Polo-run runners; see Runners. |
Chart behaviour worth knowing
Section titled “Chart behaviour worth knowing”- Trusted proxies. Every request reaches the API pods from the Ingress controller, so without
api.trustedProxiesthe per-client rate limits see one address. List where the controller connects from: normally the pod CIDR, or the node addresses for a controller onhostNetwork. The controller must pass the real client address on. Any pod that can reach the API Service directly can then claim a client address, so on a shared cluster admit only the controller and Polo’s own pods with a NetworkPolicy. - Probes. The API’s readiness probe is
/readyzand its liveness probe is/healthz. A Temporal outage answers 200 with statusdegraded, so replicas stay in the Service. - In-cluster traffic. The scheduler and worker reach the API through
publicUrl, that is, through the Ingress with its public certificate. To use an internal HTTPS Service instead, setscheduler.apiUrlandworker.apiUrl, andtls.caSecretif that certificate comes from a private CA. - Hardening. The API, relay and scheduler run with a read-only root filesystem and an
emptyDiron/tmp. Every pod setsenableServiceLinks: false, because Kubernetes would otherwise inject aPOLO_API_PORTvariable that collides with the API’s own port setting. - Development Secret.
secrets.create: truerenders the Secret fromsecrets.valuesas a pre-install hook. It is for development only and is not removed byhelm uninstall.
The engineering sandbox
Section titled “The engineering sandbox”worker.engineering.enabled mounts the node’s Docker socket and workspaceRoot (a hostPath at the same path inside the pod) and adds dockerGid to the pod’s supplemental groups. The toolchain image must exist on that node’s Docker daemon.
Migrations and provisioning
Section titled “Migrations and provisioning”Two idempotent commands run before every install or upgrade (and before every start in Compose):
polo-api migrateruns the ordered migrations inside one transaction with the owner role fromPOLO_API_MIGRATION_DATABASE_URLand prints the applied names.polo-api provisioncreates or updates the restricted application role (POLO_API_DATABASE_ROLE,POLO_API_DATABASE_PASSWORD) and the dispatch role (POLO_API_DISPATCH_ROLE,POLO_API_DISPATCH_PASSWORD) with the grants the API and relay need.
The API runs as a non-owner role that cannot bypass row-level security, and schema synchronisation is disabled, so a schema change can only arrive through this step.
The role in POLO_API_MIGRATION_DATABASE_URL needs:
- Ownership of the database.
migratecreates thepolo_*schemas with their tables, policies and functions. CREATEROLE.provisioncreates the two runtime roles and grants themCONNECT. Without it the hook stops with “Runtime role provisioning failed”.- The
vectorextension. pgvector is not a trusted extension, so unless the role is a superuser, a superuser (or your provider’s administrator role) creates it once before the firstmigrate. BYPASSRLSas well if the same role takes backups; see Backup and restore.
On an external PostgreSQL server, as a superuser:
CREATE ROLE polo_migration LOGIN CREATEROLE PASSWORD '...';CREATE DATABASE polo_product OWNER polo_migration;\c polo_productCREATE EXTENSION IF NOT EXISTS vector;On PostgreSQL 16 and later, leave the server setting createrole_self_grant empty. provision accepts the automatic ADMIN membership a non-superuser gets on roles it creates, and refuses any broader membership on a runtime role.
If the migrate Job fails, it is kept for inspection:
kubectl -n polo get jobs -l app.kubernetes.io/instance=poloUpgrade order
Section titled “Upgrade order”- Build and publish the new
polo-apiandpolo-runtimeimages (andpolo-toolsandpolo-engineering-toolsif they changed). Read the release notes for new environment variables or Secret keys and add them first. - Back up PostgreSQL. Then run the migration:
helm upgraderuns it as the pre-upgrade hook. - The API rolls out, then the relay, then the scheduler and watch processes. They all use the same image.
- The runtime worker rolls out last. It drains in-flight activities during
worker.terminationGracePeriodSeconds(120 seconds), and new workflow tasks wait on the Temporal queue until a worker polls again. Temporal keeps the state of running tasks. - Customer runners are upgraded independently by their owners from a new
polo-toolsimage. Check the release notes for a contract change before upgrading the control plane ahead of the runners.