> ## Documentation Index
> Fetch the complete documentation index at: https://pipedream.com/docs/llms.txt
> Use this file to discover all available pages before exploring further.

# Deploy on Kubernetes

> Run Conduit on Kubernetes with the Helm chart or raw manifests.

Conduit ships a Helm chart that covers both deployment tiers — embedded (a
single replica owning a persistent volume) and active-active PostgreSQL —
from one set of values. This page covers the chart first, then
[complete raw manifests](#raw-manifests-without-helm) for clusters that
don't use Helm. If you haven't picked a tier, start with
[Deployment Tiers](/docs/conduit/deploy/availability).

## Before you start

* Helm 3.8+ (OCI registry support) — or skip to the
  [raw manifests](#raw-manifests-without-helm).
* A `ReadWriteOnce` **block-storage** StorageClass (EBS, GCE PD, Azure Disk,
  Ceph RBD, local-path). Never network filesystems (NFS, EFS, Azure Files) —
  Conduit's embedded database is not safe on them.
* An Ingress controller or LoadBalancer terminating TLS. AI clients require
  HTTPS for OAuth.

## Secrets first

Conduit encrypts stored secrets with a key you provide. Create it as a
Kubernetes Secret before installing, and keep a copy somewhere safe (a
password manager or secret manager) — stored secrets are unrecoverable
without it:

```sh theme={null}
kubectl create secret generic conduit \
  --from-literal=encryption-key="$(openssl rand -base64 32)"
```

The chart **requires** this Secret (`existingSecret.name`) and never
generates one itself: a template-generated key would silently regenerate
under GitOps tooling that renders without cluster access, losing every
stored secret. A Secret also keeps the key out of data-volume snapshots, so
a leaked backup alone can't decrypt anything.

## Install with Helm

```sh theme={null}
helm install conduit oci://ghcr.io/pipedreamhq/charts/conduit \
  --version X.Y.Z \  # match a release from the changelog
  -f values.yaml
```

A minimal `values.yaml` for the embedded tier:

```yaml theme={null}
existingSecret:
  name: conduit
ingress:
  enabled: true
  host: conduit.example.com
  tls:
    secretName: conduit-tls
  annotations: {} # your controller / cert-manager annotations
```

That renders the same shape as the [raw manifests below](#embedded-tier): a
PVC, a single-replica Deployment with the `Recreate` strategy (the data
volume is `ReadWriteOnce`, so the old pod must release it before the new one
starts), a Service, and the Ingress. `CONDUIT_BASE_URL` defaults to
`https://<ingress.host>`; set `baseURL` if you terminate TLS somewhere the
chart can't see.

Each chart release pins its matching `vX.Y.Z` image, so `--version` is also
how you choose the Conduit version.

### Active-active on PostgreSQL

With an operator-supplied PostgreSQL 14+ (see
[Deployment Tiers](/docs/conduit/deploy/availability)), add the connection URL to your Secret
and point the chart at its key:

```sh theme={null}
kubectl create secret generic conduit \
  --from-literal=encryption-key="$(openssl rand -base64 32)" \
  --from-literal=database-url="postgres://conduit:...@db.example.com:5432/conduit" \
  --from-literal=admin-password="..." # first install only; remove after setup
```

```yaml theme={null}
replicas: 3
existingSecret:
  name: conduit
  adminPasswordKey: admin-password # remove once first-run setup completes
database:
  secretKey: database-url
ingress:
  enabled: true
  host: conduit.example.com
  tls:
    secretName: conduit-tls
```

Configuring the database is the tier switch — the chart derives the rest:

| | Embedded (no `database.secretKey`) | PostgreSQL |
| - | - | - |
| Storage | PVC mounted at `/data` | none — stateless pods |
| Update strategy | `Recreate` | `RollingUpdate` (zero-downtime) |
| `replicas` | must be 1 (the chart refuses more) | free |
| PodDisruptionBudget | — | rendered when `replicas > 1` |

The chart's Service and Ingress carry no sticky-session settings, and none
are needed: any replica serves any request.

On a fresh PostgreSQL database Conduit refuses to start without a bootstrap
password, so set `existingSecret.adminPasswordKey` on first install and
remove it (with a `helm upgrade`) once setup completes. Concurrent boots are
safe: migrations and startup sweeps coordinate through database locks. If
your database URL lives in a Secret managed elsewhere (e.g. written by a
PostgreSQL operator), set `database.secretName` too — it defaults to
`existingSecret.name`.

### IAM authentication to RDS

On EKS with RDS or Aurora, `CONDUIT_DATABASE_AUTH=rds-iam` replaces the
password in the database URL with per-connection IAM tokens. The AWS side —
enabling IAM auth, the `rds_iam` grant, the connect policy — is on
[Deployment Tiers](/docs/conduit/deploy/availability#iam-authentication-on-aws); the chart
wires the Kubernetes half. Mount the RDS CA bundle so `verify-full` can
validate the server certificate:

```sh theme={null}
curl -O https://truststore.pki.rds.amazonaws.com/global/global-bundle.pem
kubectl create configmap rds-ca --from-file=global-bundle.pem
```

```yaml theme={null}
serviceAccount:
  annotations:
    # IRSA: the role holding rds-db:connect. EKS Pod Identity works too —
    # associate the role with the service account and drop the annotation.
    eks.amazonaws.com/role-arn: arn:aws:iam::123456789012:role/conduit
extraEnv:
  - name: CONDUIT_DATABASE_AUTH
    value: rds-iam
  - name: AWS_REGION
    value: us-east-1
extraVolumes:
  - name: rds-ca
    configMap:
      name: rds-ca
extraVolumeMounts:
  - name: rds-ca
    mountPath: /etc/rds-ca
    readOnly: true
```

with the database URL in the Secret carrying no password and naming the
mounted bundle:

```
postgres://conduit@mydb.abc123.us-east-1.rds.amazonaws.com:5432/conduit?sslmode=verify-full&sslrootcert=/etc/rds-ca/global-bundle.pem
```

### Values reference

The chart deliberately models only what these manifests need. Every other
`CONDUIT_*` variable in the
[configuration reference](/docs/conduit/configure/reference) goes through `extraEnv`
/ `extraEnvFrom`, which also fit External Secrets or Vault workflows:

```yaml theme={null}
extraEnv:
  - name: CONDUIT_TRUSTED_PROXIES
    value: 10.0.0.0/8
```

| Value | Default | Notes |
| - | - | - |
| `existingSecret.name` | — | **Required.** Secret holding the encryption key |
| `existingSecret.encryptionKeyKey` | `encryption-key` | Key for `CONDUIT_ENCRYPTION_KEY` |
| `existingSecret.adminPasswordKey` | `""` | Key for `CONDUIT_ADMIN_PASSWORD` (first-run setup; remove after) |
| `adminEmail` | `""` | `CONDUIT_ADMIN_EMAIL` (bootstrap admin) |
| `baseURL` | derived from `ingress.host` | `CONDUIT_BASE_URL` |
| `database.secretKey` | `""` | Secret key holding a `postgres://` URL; setting it selects the PostgreSQL tier |
| `database.secretName` | `existingSecret.name` | Secret holding the database URL |
| `replicas` | `1` | `> 1` requires PostgreSQL |
| `image.repository` / `image.tag` | `ghcr.io/pipedreamhq/conduit` / the chart's release | See [private registries](#private-registries-and-air-gapped-installs) |
| `logFormat` | `json` | `CONDUIT_LOG_FORMAT`; `""` for plain text |
| `workspaceMode` | `""` (single) | `CONDUIT_WORKSPACE_MODE` |
| `persistence.size` / `.storageClassName` / `.existingClaim` | `2Gi` / cluster default / `""` | Embedded tier only |
| `ingress.enabled` / `.host` / `.className` / `.annotations` / `.tls.secretName` | disabled | The chart's Ingress |
| `service.type` / `.port` / `.annotations` | `ClusterIP` / `7272` / `{}` | Service annotations fit shared-LB setups (e.g. ALB target-group settings) with `ingress.enabled: false` |
| `metrics.enabled` | `false` | Prometheus metrics on a dedicated `metrics` container port (9464), scraped at the pod IP — never part of the Service or Ingress. See [Monitoring](/docs/conduit/deploy/monitoring) |
| `resources` | 256Mi/100m requests, 512Mi limit | Same as the manifests below |
| `extraEnv` / `extraEnvFrom` | `[]` | Any other configuration |
| `extraVolumes` / `extraVolumeMounts` | `[]` | Files the server needs on disk — e.g. the RDS CA bundle for [IAM auth](#iam-authentication-to-rds) |

Run `helm show values oci://ghcr.io/pipedreamhq/charts/conduit` for the full
annotated list (pod placement, security contexts, service account, PDB).

Uninstalling the release **keeps the data PVC** (`helm.sh/resource-policy:
keep`) — the volume is the database, so deleting it is a deliberate `kubectl`
action, never a side effect of `helm uninstall`.

## Private registries and air-gapped installs

A release is exactly two artifacts — the image and the chart, with no
sidecars, init containers, or subcharts pulling images of their own — so
copying both into your registry is a complete mirror:

```sh theme={null}
VERSION=X.Y.Z # match a release from the changelog

# The image (or docker pull / tag / push, or your registry's import UI)
skopeo copy --all \
  docker://ghcr.io/pipedreamhq/conduit:v$VERSION \
  docker://registry.example.com/conduit/conduit:v$VERSION

# The chart
helm pull oci://ghcr.io/pipedreamhq/charts/conduit --version $VERSION
helm push conduit-$VERSION.tgz oci://registry.example.com/helm
```

Keep the image tag unchanged: a released chart defaults to the `vX.Y.Z`
image tag matching its own version, so a mirror that preserves tags needs no
`image.tag` override. If your registry re-tags, set `image.tag`.

Then install from your registry. `image.repository` carries the registry
host, so pointing at the mirrored image is the only values change — plus
pull credentials if your registry requires them:

```yaml theme={null}
image:
  repository: registry.example.com/conduit/conduit
imagePullSecrets:
  - name: your-pull-secret
```

```sh theme={null}
helm install conduit oci://registry.example.com/helm/conduit \
  --version X.Y.Z \
  -f values.yaml
```

* A registry that proxies `ghcr.io` as a pull-through cache works the same
  way with no copy step — set `image.repository` to the proxied path.
* If your network can't reach `ghcr.io` at all,
  [build the image from source](/docs/conduit/deploy/build-from-source) and push it to your
  registry; everything else here applies unchanged.
* With the [raw manifests](#raw-manifests-without-helm) there is no chart to
  mirror: copy the image and edit the pinned `image:` line to match.

Once running, Conduit makes outbound requests only for the features you
configure — there is no phone-home, license check, or update poll — so a
fully air-gapped instance works as long as its connectors and identity
providers point at hosts it can reach. [Network Access](/docs/conduit/deploy/network) lists
every host and when it is contacted.

## Raw manifests (without Helm)

Complete, working manifest sets for both tiers — the same shapes the chart
renders.

### Embedded tier

```yaml theme={null}
apiVersion: v1
kind: PersistentVolumeClaim
metadata:
  name: conduit-data
spec:
  accessModes: [ReadWriteOnce]
  # storageClassName: <your block-storage class>
  resources:
    requests:
      storage: 2Gi
---
apiVersion: apps/v1
kind: Deployment
metadata:
  name: conduit
spec:
  replicas: 1
  selector:
    matchLabels:
      app: conduit
  # Recreate, not RollingUpdate: the data volume is ReadWriteOnce and Conduit
  # is single-writer, so the old pod must release the volume before the new
  # one starts. A rolling update would deadlock waiting for the mount.
  strategy:
    type: Recreate
  template:
    metadata:
      labels:
        app: conduit
    spec:
      # The image runs as a fixed non-root user; fsGroup re-owns the data
      # volume on mount.
      securityContext:
        runAsNonRoot: true
        runAsUser: 65532
        runAsGroup: 65532
        fsGroup: 65532
        seccompProfile:
          type: RuntimeDefault
      volumes:
        - name: data
          persistentVolumeClaim:
            claimName: conduit-data
      containers:
        - name: conduit
          image: ghcr.io/pipedreamhq/conduit:v0.2.0 # pin a release
          securityContext:
            allowPrivilegeEscalation: false
            capabilities:
              drop: [ALL]
          ports:
            - containerPort: 7272
          env:
            - name: CONDUIT_BASE_URL
              value: https://conduit.example.com
            - name: CONDUIT_LOG_FORMAT
              value: json
            - name: CONDUIT_ENCRYPTION_KEY
              valueFrom:
                secretKeyRef:
                  name: conduit
                  key: encryption-key
          volumeMounts:
            - name: data
              mountPath: /data
          resources:
            requests:
              memory: 256Mi
              cpu: 100m
            limits:
              memory: 512Mi
          readinessProbe:
            httpGet:
              path: /healthz/ready # verifies database connectivity
              port: 7272
            # Probe fast: the server listens well within a second of start,
            # and with Recreate every second before Ready is downtime.
            periodSeconds: 2
          livenessProbe:
            httpGet:
              path: /healthz
              port: 7272
---
apiVersion: v1
kind: Service
metadata:
  name: conduit
spec:
  selector:
    app: conduit
  ports:
    - port: 7272
      targetPort: 7272
---
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
  name: conduit
  # Add your controller/cert-manager annotations here.
spec:
  tls:
    - hosts: [conduit.example.com]
      secretName: conduit-tls
  rules:
    - host: conduit.example.com
      http:
        paths:
          - path: /
            pathType: Prefix
            backend:
              service:
                name: conduit
                port:
                  number: 7272
```

### Active-active on PostgreSQL

Relative to the manifests above, the Deployment becomes stateless and scales
horizontally:

```yaml theme={null}
apiVersion: apps/v1
kind: Deployment
metadata:
  name: conduit
spec:
  replicas: 3
  strategy:
    type: RollingUpdate # stateless now — no volume to hand over
  selector:
    matchLabels:
      app: conduit
  template:
    metadata:
      labels:
        app: conduit
    spec:
      securityContext:
        runAsNonRoot: true
        runAsUser: 65532
        runAsGroup: 65532
        fsGroup: 65532
        seccompProfile:
          type: RuntimeDefault
      containers:
        - name: conduit
          image: ghcr.io/pipedreamhq/conduit:v0.2.0 # pin a release
          securityContext:
            allowPrivilegeEscalation: false
            capabilities:
              drop: [ALL]
          ports:
            - containerPort: 7272
          env:
            - name: CONDUIT_BASE_URL
              value: https://conduit.example.com
            - name: CONDUIT_LOG_FORMAT
              value: json
            - name: CONDUIT_DATABASE_URL
              valueFrom:
                secretKeyRef: { name: conduit, key: database-url }
            - name: CONDUIT_ENCRYPTION_KEY
              valueFrom:
                secretKeyRef: { name: conduit, key: encryption-key }
            # Required until first-run setup completes; remove afterwards.
            - name: CONDUIT_ADMIN_PASSWORD
              valueFrom:
                secretKeyRef: { name: conduit, key: admin-password }
          resources:
            requests:
              memory: 256Mi
              cpu: 100m
            limits:
              memory: 512Mi
          readinessProbe:
            httpGet:
              path: /healthz/ready
              port: 7272
            periodSeconds: 2
          livenessProbe:
            httpGet:
              path: /healthz
              port: 7272
```

What changed and why:

* **No PVC, no volume mount, `RollingUpdate`** — all state lives in
  PostgreSQL, so updates are zero-downtime.
* **`CONDUIT_DATABASE_URL`, `CONDUIT_ENCRYPTION_KEY`, and (pre-setup)
  `CONDUIT_ADMIN_PASSWORD`** come from Secrets. Conduit refuses to start
  without the key, and without the password on a fresh database — the
  failure messages say why.
* **Readiness (`/healthz/ready`) verifies database connectivity**, so the
  Service ejects an instance that lost PostgreSQL; liveness stays
  process-local. Concurrent boots are safe — migrations and startup sweeps
  coordinate through database locks.
* **No session affinity.** Any replica serves any request — including the
  answer to an in-band MCP prompt or a request cancellation, which Conduit
  hands to the replica holding the call — so the Service and Ingress need no
  sticky-session settings.

## Notes (both paths)

* **`CONDUIT_BASE_URL` must match the Ingress host** — it's what OAuth
  callbacks and MCP discovery hand to clients.
* **Set `CONDUIT_TRUSTED_PROXIES` to your pod CIDR**
  (`kubectl cluster-info dump | grep -m1 cluster-cidr`). Conduit only honors
  `X-Forwarded-For` from proxies you declare, and an in-cluster ingress
  controller is not trusted by default; without it, audit entries and
  per-IP rate limits collapse onto the ingress address. With the chart, set
  it via `extraEnv` (the example above). See
  [client IP attribution](/docs/conduit/configure/reference#client-ip-attribution).
* **MCP clients hold long-lived SSE connections.** If your ingress enforces
  a short proxy read timeout, raise it for this host — e.g. ingress-nginx:
  `nginx.ingress.kubernetes.io/proxy-read-timeout: "3600"`.
* **Any L7 load balancer works with its defaults.** Replicas need no
  session affinity (see
  [Deployment Tiers](/docs/conduit/deploy/availability#active-active-n-replicas)). An AWS ALB
  can stay on round-robin with stickiness off; Conduit writes a keep-alive
  frame on every idle MCP stream at least every 30 seconds, inside the ALB's
  default 60-second idle timeout.
* On AZ-bound storage (EBS and friends), use a StorageClass with
  `volumeBindingMode: WaitForFirstConsumer` so the volume is created in the
  AZ the pod actually schedules to.
* **Prometheus metrics** are opt-in: `metrics.enabled: true` with the chart,
  or (raw manifests) `CONDUIT_METRICS_ADDR=0.0.0.0:9464` plus a matching
  `metrics` container port. Scrape at the pod IP; keep the port out of the
  Service and Ingress. See [Monitoring](/docs/conduit/deploy/monitoring).
* **A load balancer with its own target health checks** (an AWS ALB, a cloud
  LB) is usually the slowest link in an embedded-tier upgrade: point its
  check at `/healthz/ready`, tighten the interval, and shorten any
  deregistration/draining delay — the old target is already dead when it
  deregisters, so draining buys nothing.

## Validate the deployment

A green readiness probe means the pod serves traffic and can reach its
database. To confirm the deployment end to end, walk the same path a user
will — each step exercises a different piece of configuration:

1. **Probes** — `/healthz` (process) and `/healthz/ready` (database
   connectivity) return 200 through your Service.
2. **Sign in** — open `https://<your-host>` in a browser and complete
   first-run setup (or sign in). This proves TLS, the Ingress, and session
   cookies.
3. **Connect an AI client** — add `https://<your-host>/mcp` to any MCP client
   and complete the sign-in it opens. This is the sharpest test of
   `CONDUIT_BASE_URL`: the OAuth flow round-trips through the externally
   reachable address, so a mismatch fails here and nowhere earlier. The
   client should list Conduit's built-in tools.
4. **Call a tool** — any built-in tool (ask the client "what session am I
   using?"), then confirm the call appears in the audit log and usage
   dashboard. That proves the whole loop: authorization, execution,
   recording.
5. **Check IP attribution** — if the audit entries show your ingress or
   load-balancer address as the client IP, set `CONDUIT_TRUSTED_PROXIES`
   (see [Notes](#notes-both-paths)).

Adding an identity provider and a first connector after that follows
[Single Sign-On](/docs/conduit/configure/sso) and
[Connectors](/docs/conduit/configure/connectors).

## Upgrades

Read the release's **Upgrade notes** in the
[changelog](/docs/conduit/changelog#unreleased) first, then upgrade deliberately:
`helm upgrade --version X.Y.Z` with the chart (which moves the image in
lockstep), or bump the pinned image tag with raw manifests. On the embedded
tier the `Recreate` strategy means an upgrade takes the seconds between pod
stop and ready — sessions and tokens live in the database, so nobody is
signed out and MCP clients reconnect on their own.

To track releases automatically, run your rollout from CI: watch the
`:stable` tag (or GitHub releases) and apply the new pinned version when one
appears, recording it in your deploy annotations so rollout history maps to
releases.
