# Kubernetes (Helm) (/docs/deployment/kubernetes-helm)



Ductor ships a Helm chart at `deploy/helm/ductor`. It runs Ductor as a
Deployment with autoscaling, a PodDisruptionBudget, a pre-install/pre-upgrade
migration Job, and an optional Prometheus `ServiceMonitor` — with a hard
enterprise-posture gate for production.

Chart and image versions below are the pinned values **as of writing**;
`Chart.yaml` and `values.yaml` are the live source.

| Chart        | Value (as of writing)       |
| ------------ | --------------------------- |
| `name`       | `ductor`                    |
| `version`    | `0.1.0`                     |
| `appVersion` | `1.0.0` (default image tag) |

## Install (development) [#install-development]

```bash
helm upgrade --install ductor deploy/helm/ductor \
  --set postgres.host=timescaledb.example.test \
  --set postgres.password=development-only \
  --set redis.host=dragonfly.example.test
```

This is the `production=false` posture: the chart may create database and cache
Secrets from inline values and runs the development profile. It does **not** mount
a connector encryption key, so never store real connector credentials in it.

## Key values [#key-values]

<TypeTable
  type="{
  &#x22;production&#x22;: { type: &#x22;boolean&#x22;, default: &#x22;false&#x22;, description: &#x22;Enforce external Secrets + enterprise posture (see below)&#x22; },
  &#x22;replicaCount&#x22;: { type: &#x22;number&#x22;, default: &#x22;3&#x22;, description: &#x22;Base replica count when autoscaling is off&#x22; },
  &#x22;image.repository&#x22;: { type: &#x22;string&#x22;, default: &#x22;ductor&#x22;, description: &#x22;Image repo&#x22; },
  &#x22;image.tag&#x22;: { type: &#x22;string&#x22;, default: &#x22;\&#x22;\&#x22;&#x22;, description: &#x22;Empty → uses appVersion&#x22; },
  &#x22;service.http.port&#x22;: { type: &#x22;number&#x22;, default: &#x22;8080&#x22;, description: &#x22;REST + Connect&#x22; },
  &#x22;service.grpc.port&#x22;: { type: &#x22;number&#x22;, default: &#x22;50051&#x22;, description: &#x22;Targets the same api container listener as http&#x22; },
  &#x22;service.metrics.port&#x22;: { type: &#x22;number&#x22;, default: &#x22;9090&#x22;, description: &#x22;Prometheus&#x22; },
  &#x22;resources.requests&#x22;: { type: &#x22;string&#x22;, default: &#x22;256Mi / 250m&#x22;, description: &#x22;Memory / CPU requests&#x22; },
  &#x22;resources.limits&#x22;: { type: &#x22;string&#x22;, default: &#x22;1Gi / 1000m&#x22;, description: &#x22;Memory / CPU limits&#x22; },
  &#x22;autoscaling.enabled&#x22;: { type: &#x22;boolean&#x22;, default: &#x22;true&#x22;, description: &#x22;HPA on&#x22; },
  &#x22;autoscaling.minReplicas / maxReplicas&#x22;: { type: &#x22;number&#x22;, default: &#x22;3 / 10&#x22;, description: &#x22;HPA bounds&#x22; },
  &#x22;autoscaling.targetCPUUtilizationPercentage&#x22;: { type: &#x22;number&#x22;, default: &#x22;70&#x22;, description: &#x22;CPU target (0 disables)&#x22; },
  &#x22;autoscaling.targetMemoryUtilizationPercentage&#x22;: { type: &#x22;number&#x22;, default: &#x22;80&#x22;, description: &#x22;Memory target (0 disables)&#x22; },
  &#x22;podDisruptionBudget.minAvailable&#x22;: { type: &#x22;number&#x22;, default: &#x22;2&#x22;, description: &#x22;PDB&#x22; },
  &#x22;migrations.enabled&#x22;: { type: &#x22;boolean&#x22;, default: &#x22;true&#x22;, description: &#x22;Run migrations via a pre-install/pre-upgrade Job&#x22; },
  &#x22;serviceMonitor.enabled&#x22;: { type: &#x22;boolean&#x22;, default: &#x22;false&#x22;, description: &#x22;Prometheus Operator scraping (rejected in production)&#x22; },
  &#x22;ingress.enabled&#x22;: { type: &#x22;boolean&#x22;, default: &#x22;false&#x22;, description: &#x22;HTTP ingress (separate ingress.grpc block)&#x22; },
}"
/>

<Callout title="One listener, two Service ports">
  The runtime serves REST, Connect, and the health routes from a single `api`
  listener. Both the `http` and `grpc` Service ports target that same container
  port — there is no separate gRPC listener.
</Callout>

## Config and probes [#config-and-probes]

The Deployment sets non-secret config from a ConfigMap and injects secrets as env
vars. ConfigMap and Secret checksums are stamped as pod annotations, so a config
change triggers a rolling restart automatically. Probes:

| Probe            | Path         | Notes                                                                     |
| ---------------- | ------------ | ------------------------------------------------------------------------- |
| `startupProbe`   | `GET /livez` | Process-only; no dependency coupling                                      |
| `readinessProbe` | `GET /ready` | Removes a pod from service during dependency failure                      |
| `livenessProbe`  | `GET /livez` | Process-only, so a shared dependency outage doesn't restart every replica |

When `tls.existingSecret` is set (the enterprise path) the probes switch to
**HTTPS**, because the combined API listener terminates TLS. Enterprise probes
then require `probes.host`: kubelet's default Pod-IP `Host` header is rejected by
the enterprise allowed-hosts policy, so you must set an explicit probe hostname
**and** include that exact name in `api.allowed_hosts` inside `DUCTOR_CONFIG`, or
every probe fails and the pod never becomes ready.

## Pod hardening [#pod-hardening]

The chart ships defaults that satisfy the Kubernetes [restricted Pod Security
Standard](https://kubernetes.io/docs/concepts/security/pod-security-standards/),
so a default install is admitted by a `restricted`-PSS namespace with no extra
tuning. The image runs as uid/gid 1000 (see `docker/Dockerfile`).

| Level     | Setting                                | Default          |
| --------- | -------------------------------------- | ---------------- |
| Pod       | `runAsNonRoot`                         | `true`           |
| Pod       | `runAsUser` / `runAsGroup` / `fsGroup` | `1000`           |
| Pod       | `seccompProfile.type`                  | `RuntimeDefault` |
| Container | `allowPrivilegeEscalation`             | `false`          |
| Container | `readOnlyRootFilesystem`               | `true`           |
| Container | `capabilities.drop`                    | `[ALL]`          |

Both the application Deployment and the migration hook Job carry the same pod-
and container-level contexts.

<Callout title="Read-only root filesystem needs a writable /tmp">
  Because `readOnlyRootFilesystem` is `true`, the chart mounts an `emptyDir` at
  `/tmp` for scratch space — in **both** the Deployment and the `<release>-migrate`
  Job. If you enable local archival (`archival.backend=local`), add a separate
  writable volume for `archival.storage_path`; `/tmp` is scratch only.
</Callout>

### Graceful shutdown [#graceful-shutdown]

`terminationGracePeriodSeconds` defaults to **60**. On termination the pod flips
readiness, waits `shutdown_drain_delay` (default `5s`), then drains in-flight
work — so the grace period **must exceed** `shutdown_drain_delay` plus enough
headroom to finish that drain. A value inside the drain window gets pods
`SIGKILL`ed mid-drain. If you raise `shutdown_drain_delay`, raise
`terminationGracePeriodSeconds` to match.

## Secrets [#secrets]

The chart manages Secrets when you don't supply an `existingSecret`, or reads
your own when you do:

| Secret            | Chart-managed key                         | Injected as           |
| ----------------- | ----------------------------------------- | --------------------- |
| Database          | `DATABASE_URL`                            | `DUCTOR_DATABASE_URL` |
| Redis             | `REDIS_URL`                               | `DUCTOR_CACHE_URL`    |
| Connector         | `DUCTOR_CONNECTOR_ENCRYPTION_KEY`         | same                  |
| Runtime config    | `DUCTOR_CONFIG`                           | `DUCTOR_CONFIG`       |
| Stripe (optional) | `STRIPE_API_KEY`, `STRIPE_WEBHOOK_SECRET` | corresponding vars    |

<Callout title="Redis secret key vs env var">
  The redis Secret key is `REDIS_URL`, but it is injected into the container as
  `DUCTOR_CACHE_URL` — the names intentionally differ. Secret key names are
  independent from the canonical runtime env names and can be overridden with the
  matching `*.existingSecretKey` values.
</Callout>

## Migrations run as a Helm hook [#migrations-run-as-a-helm-hook]

`migrations.enabled` (default `true`) renders a `batch/v1` Job,
`<release>-migrate`, with Helm hooks `pre-install,pre-upgrade`. Its container runs
`ductor migrate up`, so under the chart migrations run **before** the new pods
roll — the application deliberately keeps `database.auto_migrate` off in
Kubernetes. Tune it with `migrations.backoffLimit` (default `1`) and
`migrations.activeDeadlineSeconds` (default `600`).

The Job is **retained on failure**: a failed migration blocks the release and
stays available for inspection.

```mermaid
flowchart TD
  A["helm install / upgrade"] --> B["pre-install / pre-upgrade Job<br/>ductor migrate up"]
  B -->|success| C["Roll new pods"]
  B -->|failure| D["Block release<br/>Job retained for inspection"]
```

```bash
kubectl logs job/ductor-migrate
kubectl describe job/ductor-migrate
```

<Callout type="warn" title="Rollback is operator-controlled, not automatic">
  Migration rollback is a deliberate database recovery, **not** an automatic Helm
  rollback. Stop the rollout, inspect the retained Job, restore the pre-migration
  database snapshot if needed, and only then roll back the chart revision. A Helm
  rollback alone does not undo applied schema changes. GitOps operators that run
  migrations separately may set `migrations.enabled=false`, but must run
  `ductor migrate up` before application pods start.
</Callout>

## The production gate [#the-production-gate]

Setting `production: true` turns on a fail-closed guard rail: the chart
**refuses to render** unless the whole enterprise posture is in place. It is not
a tuning knob — it is a wall that keeps a half-hardened cluster from ever being
deployed. Rendering fails unless:

* `config.environment=production` and `config.securityProfile=enterprise`.
* Database, Redis, the connector encryption key, API TLS, and the complete
  runtime config all come from **pre-provisioned Secrets** — no inline values.
* `config.metricsHost` is a loopback address (enterprise metrics bind loopback).

Two constraints trip people up most often:

* **`serviceAccount.create` must be `false`** in production. Helm cannot create
  an ordinary chart ServiceAccount *before* a `pre-install` hook, so the account
  must already exist. Set `serviceAccount.name` to a non-default,
  operator-pre-provisioned account; the Deployment and the migration Job both use
  that exact account, with API-token automount disabled and no RBAC granted.
* **`serviceMonitor.enabled=true` is rejected** in production. Enterprise metrics
  bind to loopback, and a cluster ServiceMonitor cannot scrape a loopback
  listener directly — you need a secured in-pod metrics proxy instead.

<Callout type="warn" title="config.existingSecret and connector.existingSecret must be the same Secret">
  Both must name the **same** Kubernetes Secret object, so the active connector
  key and the `DUCTOR_CONFIG` document (which carries the matching
  `connector.encryption_key_id` and decrypt-only `connector.rotation_keys`
  keyring) can never be observed at different revisions.
</Callout>

### Rolling pods when external Secrets change [#rolling-pods-when-external-secrets-change]

The chart checksums the config it manages, but **Helm cannot checksum the
contents of an external Secret**. `config.externalSecretsRevision` is a
non-secret value you bump whenever a referenced external Secret changes; changing
it rolls the pod template so the new Secret contents are picked up. (Alternatively
run an explicit `kubectl rollout restart` after the coordinated Secret update.)

### Enterprise install [#enterprise-install]

```bash
helm upgrade --install ductor deploy/helm/ductor \
  --set production=true \
  --set config.environment=production \
  --set config.securityProfile=enterprise \
  --set config.existingSecret=ductor-runtime-config \
  --set config.metricsHost=127.0.0.1 \
  --set config.externalSecretsRevision=2026-07-11-1 \
  --set probes.host=ductor-probe.internal \
  --set postgres.existingSecret=ductor-database \
  --set redis.existingSecret=ductor-cache \
  --set connector.existingSecret=ductor-runtime-config \
  --set tls.existingSecret=ductor-api-tls \
  --set serviceAccount.create=false \
  --set serviceAccount.name=ductor-runtime
```

Note `config.existingSecret` and `connector.existingSecret` naming the same
Secret (`ductor-runtime-config`). See the chart's `README.md` for the connector
key-rotation and HMAC-key procedures, and
[Production concerns](/docs/deployment/production) for the config behind these
switches.

## ServiceMonitor (non-production) [#servicemonitor-non-production]

Outside the enterprise posture, enable Prometheus Operator scraping of the
metrics port:

```yaml
serviceMonitor:
  enabled: true
  interval: 30s
  scrapeTimeout: 10s
```

<Callout type="warn" title="Unscraped metrics fire no alerts">
  With `serviceMonitor.enabled=false` (the default) the pods still expose
  `/metrics` on `:9090`, but nothing scrapes them — so Prometheus alert rules
  never fire. The chart prints a post-install `NOTES` warning to this effect.
  Either set `serviceMonitor.enabled=true` (if prometheus-operator's
  ServiceMonitor CRD is installed) or point your Prometheus scrape config at the
  metrics port directly.
</Callout>

See [Observability](/docs/operations/observability) for the metrics surface.
