---
title: Kubernetes
path: integrations/kubernetes
status: published
---

# Kubernetes

Three patterns for pulling ScaiVault secrets into Kubernetes pods, from simplest to most flexible.

## Patterns at a glance

| Pattern | When to use | Trade-offs |
|---------|-------------|------------|
| **Init container** | Most common. Secrets needed at process startup. | Restart-needed for rotation. |
| **Sidecar** | Long-running services with rotation. | More moving parts; reload signal needed. |
| **CSI driver** | Multiple secrets, mountpoint semantics, no app changes. | Cluster-wide install; opinionated mount layout. |

For dynamic secrets (per-pod DB credentials), the sidecar pattern is the right answer — it can renew and revoke leases over the pod's lifetime.

## Authentication

Workload Identity is the modern path. Map a Kubernetes service account to a ScaiKey identity via OIDC token exchange:

```yaml
apiVersion: v1
kind: ServiceAccount
metadata:
  name: billing-prod
  namespace: billing
  annotations:
    scaikey.scailabs.ai/role: "sa:billing-prod"
```

The pod's projected service-account token is exchanged for a ScaiVault bearer token at startup via `POST /v1/auth/exchange`. No long-lived secret material in cluster.

If your cluster doesn't have OIDC configured for ScaiKey yet, fall back to a static client-credentials secret stored in a Kubernetes Secret. The exchange flow is preferred long-term.

## Pattern 1: init container

```yaml
apiVersion: apps/v1
kind: Deployment
metadata:
  name: billing
spec:
  template:
    spec:
      serviceAccountName: billing-prod
      initContainers:
      - name: fetch-secrets
        image: scailabs/scaivault-init:1.0
        env:
        - name: SCAIVAULT_URL
          value: https://scaivault.scailabs.ai
        - name: SCAIVAULT_TOKEN_FILE
          value: /var/run/secrets/scaivault/token
        - name: SECRETS
          value: |
            environments/production/billing/database -> /run/secrets/db.json
            environments/production/billing/stripe   -> /run/secrets/stripe.json
        volumeMounts:
        - {name: secrets, mountPath: /run/secrets}
        - {name: sv-token, mountPath: /var/run/secrets/scaivault, readOnly: true}
      containers:
      - name: app
        image: acme/billing:1.0
        volumeMounts:
        - {name: secrets, mountPath: /run/secrets, readOnly: true}
      volumes:
      - {name: secrets, emptyDir: {medium: Memory}}
      - name: sv-token
        projected:
          sources:
          - serviceAccountToken:
              audience: scaivault
              expirationSeconds: 3600
              path: token
```

`emptyDir.medium: Memory` keeps the secrets off disk. `projected serviceAccountToken` gives the init container a freshly-projected ScaiKey-compatible JWT.

The init container does:

1. Read its SA token.
2. Exchange for a ScaiVault bearer token via `/v1/auth/exchange`.
3. Read each secret listed in `$SECRETS`.
4. Write to the indicated file in the shared `emptyDir`.
5. Exit 0.

App reads from `/run/secrets/db.json` like any local file. No SDK in the app needed.

### Rotation with init containers

Restart the pods periodically. A `CronJob`:

```yaml
apiVersion: batch/v1
kind: CronJob
metadata:
  name: rolling-restart-billing
spec:
  schedule: "0 3 * * *"   # daily at 03:00 UTC
  jobTemplate:
    spec:
      template:
        spec:
          serviceAccountName: deployment-restarter
          containers:
          - name: kubectl
            image: bitnami/kubectl:1.30
            command: ["kubectl", "rollout", "restart", "deployment/billing"]
          restartPolicy: OnFailure
```

Crude but works. For finer-grained rotation, use the sidecar pattern.

## Pattern 2: sidecar

The sidecar runs alongside the app, fetches secrets, and refreshes them on a schedule or on event. The app reloads when the file changes.

```yaml
spec:
  containers:
  - name: app
    image: acme/billing:1.0
    volumeMounts:
    - {name: secrets, mountPath: /run/secrets, readOnly: true}
  - name: scaivault-sidecar
    image: scailabs/scaivault-sidecar:1.0
    env:
    - {name: SCAIVAULT_URL, value: https://scaivault.scailabs.ai}
    - name: WATCH_PATHS
      value: |
        environments/production/billing/database -> /run/secrets/db.json
        environments/production/billing/stripe   -> /run/secrets/stripe.json
    - {name: REFRESH_INTERVAL, value: "5m"}
    - {name: RELOAD_SIGNAL, value: "SIGHUP"}
    - {name: RELOAD_TARGET, value: "billing"}
    volumeMounts:
    - {name: secrets, mountPath: /run/secrets}
    - {name: sv-token, mountPath: /var/run/secrets/scaivault, readOnly: true}
  shareProcessNamespace: true   # for signal delivery
  volumes:
  - {name: secrets, emptyDir: {medium: Memory}}
  - name: sv-token
    projected:
      sources:
      - serviceAccountToken:
          audience: scaivault
          expirationSeconds: 3600
          path: token
```

The sidecar polls every `REFRESH_INTERVAL`, compares the fetched value against the current file, and on change writes the new value + sends `SIGHUP` to the app process. App needs to handle the signal (or watch the file with `inotify`).

For event-driven refresh instead of polling, subscribe the sidecar to `secret.rotated` for the configured paths and refresh on event. Lower latency but adds network dependency.

### Sidecar with dynamic credentials

For Postgres dynamic creds, the sidecar generates the lease and renews it:

```yaml
- name: scaivault-sidecar
  image: scailabs/scaivault-sidecar:1.0
  env:
  - name: DYNAMIC
    value: |
      engine=postgres-prod role=readonly ttl=2h -> /run/secrets/db.json
  - {name: SCAIVAULT_URL, value: https://scaivault.scailabs.ai}
```

On startup: generate, write. Periodically: renew. On shutdown: revoke. The pod gets a fresh DB user on each restart and revokes it on exit.

## Pattern 3: CSI driver

The Secret Store CSI driver mounts secrets as volumes. Install the ScaiVault provider:

```bash
helm repo add scailabs https://charts.scailabs.ai
helm install scaivault-csi scailabs/secrets-store-csi-driver-scaivault \
  --namespace kube-system
```

Then a `SecretProviderClass`:

```yaml
apiVersion: secrets-store.csi.x-k8s.io/v1
kind: SecretProviderClass
metadata:
  name: billing-secrets
  namespace: billing
spec:
  provider: scaivault
  parameters:
    base_url: https://scaivault.scailabs.ai
    audience: scaivault
    objects: |
      - path: environments/production/billing/database
        file: db.json
      - path: environments/production/billing/stripe
        file: stripe.json
        field: secret_key
```

The `field` selector pulls one field instead of the whole JSON.

Pod consuming it:

```yaml
spec:
  serviceAccountName: billing-prod
  containers:
  - name: app
    image: acme/billing:1.0
    volumeMounts:
    - name: secrets
      mountPath: /run/secrets
      readOnly: true
  volumes:
  - name: secrets
    csi:
      driver: secrets-store.csi.k8s.io
      readOnly: true
      volumeAttributes:
        secretProviderClass: billing-secrets
```

CSI handles auth via the service account's projected token automatically. Secrets appear as files at `/run/secrets/db.json` etc. Rotation: enable the driver's rotation reconciler (`--enable-secret-rotation=true`) and it polls and updates the files at the configured interval.

## Common questions

**"Can I sync ScaiVault into native Kubernetes Secrets?"** Yes, via `syncSecret` on the CSI driver's `SecretProviderClass`. The driver materializes the values into a Kubernetes Secret object. Useful for things that expect to read from native Secrets (some Helm charts, third-party operators). Less recommended for application code — the indirection adds a refresh problem.

**"What about secrets that need to exist before the pod starts?"** Use a `Job` that runs before the deployment rolls out, has access to the right ScaiKey identity, and writes a native Kubernetes Secret. ArgoCD can sequence this via sync waves.

**"How do I rotate the kubelet's projected token?"** You don't. Kubelet refreshes projected service-account tokens automatically; ScaiVault auth-exchange handles short-lived tokens by re-exchanging on each call. Don't cache exchanged tokens longer than ~50 minutes.

**"Should the init container exit cleanly if ScaiVault is down?"** Default behavior: fail. The pod doesn't start. Better than starting with stale or missing credentials. Override with `FAIL_OPEN=true` if you have an explicit fallback path.

## What's next

- [Terraform](./terraform) — provisioning ScaiVault resources from Kubernetes manifests' adjacent config.
- [Dynamic Postgres tutorial](../tutorials/dynamic-postgres-credentials) — works well with the sidecar pattern.
- [Migrate from .env files](../tutorials/migrate-from-env-files) — typical entry point.
