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

# Deployment Options

> Install BOW on your own server using Docker or Kubernetes

## Install with Docker

***

You can install BOW with a single docker command. By default, it will use SQLite as the database.
You can also configure it to use PostgreSQL by passing `BOW_DATABASE_URL` environment variable.

```bash theme={null}
docker run --pull always -d -p 3000:3000 bagofwords/bagofwords
```

> [!NOTE]
>
> To use PostgreSQL, you need to set the `BOW_DATABASE_URL` environment variable.
> For example: `BOW_DATABASE_URL=postgresql://postgres:postgres@localhost:5432/bagofwords`

### Update

* Re-run the same `docker run --pull always ...` command to fetch and start the latest image.
* Optionally, pull explicitly and restart:
  ```bash theme={null}
  docker pull bagofwords/bagofwords:latest
  # stop/remove your existing container if needed, then start again
  # docker stop <container_name> && docker rm <container_name>
  docker run --pull always -d -p 3000:3000 bagofwords/bagofwords
  ```

## Install with Docker Compose

***

Run BOW with Docker Compose and Caddy (built-in TLS on port 443). We recommend using the canonical files from the repo to avoid drift:

* `docker-compose.yaml`: [https://github.com/bagofwords1/bagofwords/blob/main/docker-compose.yaml](https://github.com/bagofwords1/bagofwords/blob/main/docker-compose.yaml)
* `Caddyfile`: [https://github.com/bagofwords1/bagofwords/blob/main/Caddyfile](https://github.com/bagofwords1/bagofwords/blob/main/Caddyfile)

### Steps

1. Make sure Docker and Docker Compose are installed.
2. Clone the repo:
   ```bash theme={null}
   git clone https://github.com/bagofwords1/bagofwords
   cd bagofwords
   ```
3. Create a `.env` file (for domain and credentials). Example:
   ```env theme={null}
   # Domain used by Caddy for HTTPS (must resolve to your server's public IP)
   DOMAIN=yourdomain.com
   # PostgreSQL (use stronger values for production)
   POSTGRES_USER=bow
   POSTGRES_PASSWORD=your_secure_pw
   POSTGRES_DB=bagofwords
   # Optional but recommended: encryption key (Fernet, 44 chars incl. '=')
   # Generate with OpenSSL: openssl rand -base64 32 | tr '+/' '-_'
   BOW_ENCRYPTION_KEY=
   ```
   Generate `BOW_ENCRYPTION_KEY` with OpenSSL:
   ```bash theme={null}
   openssl rand -base64 32 | tr '+/' '-_'
   ```
4. Start services:
   ```bash theme={null}
   docker compose up -d
   ```
5. Point your domain to the server's public IP:
   * Create an A record for `yourdomain.com` → your instance public IP.
   * Caddy will automatically obtain/renew the TLS certificate and serve on port 443.
6. Open `https://yourdomain.com`

> [!NOTE]
>
> Caddy is included by default as a reverse proxy on port 443. If you prefer to run without Caddy, remove the `caddy` service from `docker-compose.yaml` and expose the app directly on port 3000. For local, no-SSL testing, you can also use `docker-compose.dev.yaml`.

### Update

```bash theme={null}
# pull latest images and recreate containers
docker compose pull
docker compose up -d
```

## Air-gapped downloads

Download the bundle on a connected machine, then transfer it to your offline environment. Choose the architecture of the destination Linux host: `amd64` for Intel/AMD or `arm64` for ARM.

Each bundle includes the BOW, PostgreSQL, and Caddy container images, Docker Compose configuration, and an installation README. The destination host needs Docker Engine and Docker Compose v2 installed.

### Download URL pattern

Find a version in [GitHub Releases](https://github.com/bagofwords1/bagofwords/releases/latest). Use the version number without the leading `v` and replace `{arch}` with `amd64` or `arm64`:

```text theme={null}
https://s3.us-west-2.amazonaws.com/bagofwords.com/releases/{version}/bagofwords-airgap-{version}-linux-{arch}.tar.gz
```

Append `.sha256` to the download URL for its checksum file. The architecture-independent source archive uses:

```text theme={null}
https://s3.us-west-2.amazonaws.com/bagofwords.com/releases/{version}/bagofwords-source-{version}.tar.gz
```

The source archive contains tracked source code and a `SOURCE_COMMIT` file identifying the build commit; it does not include Python or Node.js dependencies.

### Example: version 0.0.587

| Package | Archive | Checksum |
| - | - | - |
| Linux AMD64 (Intel/AMD) | [Download](https://s3.us-west-2.amazonaws.com/bagofwords.com/releases/0.0.587/bagofwords-airgap-0.0.587-linux-amd64.tar.gz) | [SHA256](https://s3.us-west-2.amazonaws.com/bagofwords.com/releases/0.0.587/bagofwords-airgap-0.0.587-linux-amd64.tar.gz.sha256) |
| Linux ARM64 | [Download](https://s3.us-west-2.amazonaws.com/bagofwords.com/releases/0.0.587/bagofwords-airgap-0.0.587-linux-arm64.tar.gz) | [SHA256](https://s3.us-west-2.amazonaws.com/bagofwords.com/releases/0.0.587/bagofwords-airgap-0.0.587-linux-arm64.tar.gz.sha256) |
| Source code (all architectures) | [Download](https://s3.us-west-2.amazonaws.com/bagofwords.com/releases/0.0.587/bagofwords-source-0.0.587.tar.gz) | [SHA256](https://s3.us-west-2.amazonaws.com/bagofwords.com/releases/0.0.587/bagofwords-source-0.0.587.tar.gz.sha256) |

Download the archive and matching checksum into the same directory, then verify before extracting. For example, on Linux:

```bash theme={null}
sha256sum -c bagofwords-airgap-0.0.587-linux-amd64.tar.gz.sha256
tar -xzf bagofwords-airgap-0.0.587-linux-amd64.tar.gz
```

Follow the README inside `bagofwords-airgap/` for offline installation and internal TLS configuration.

## Other configurations

***

You can also configure additional settings in the bow-config.yaml file.

```yaml theme={null}
# bow-config.yaml

# Deployment Configuration
base_url: http://0.0.0.0:3000

database:
  url: ${BOW_DATABASE_URL}

# Feature Flags
features:
  allow_uninvited_signups: false
  allow_multiple_organizations: false
  verify_emails: false

google_oauth:
  enabled: false
  client_id: ${BOW_GOOGLE_CLIENT_ID}
  client_secret: ${BOW_GOOGLE_CLIENT_SECRET}

smtp_settings:
  host: "smtp.resend.com"
  port: 587
  username: "resend"
  password: ${BOW_SMTP_PASSWORD}

encryption_key: ${BOW_ENCRYPTION_KEY}

intercom:
  enabled: true
```

To use the custom config file, you can run the following command:

```bash theme={null}
docker run --pull always -d -p 3000:3000 -v $(pwd)/bow-config.yaml:/app/bow-config.yaml bagofwords/bagofwords
```

## Install with Kubernetes

***

The `bow/bagofwords` Helm chart (chart version `3.0.0`) runs BOW on any Kubernetes cluster. It deploys:

* A `Deployment` running the `bagofwords/bagofwords` image on port `3000`, with startup, readiness and liveness probes on `/health`
* A `ClusterIP` `Service` and an `Ingress` (class `nginx` by default)
* A `ServiceAccount` (named `bowapp` by default) that you can annotate for IRSA or Workload Identity
* A `ConfigMap` holding environment variables and the generated `bow-config.yaml`, mounted at `/app/bow-config.yaml`
* Optionally, a bundled single-instance PostgreSQL 17 (a `StatefulSet` running the official `postgres:17-bookworm` image), enabled by default. The chart has no chart dependencies.
* Optionally, a `HorizontalPodAutoscaler`, `PodDisruptionBudget` and `NetworkPolicy` (all disabled by default)

**Prerequisites**

* A Kubernetes cluster and `kubectl` access to it
* Helm 3.8 or newer
* An ingress controller (the chart defaults to `ingress-nginx`), or set `ingress.enabled=false` and expose the `Service` yourself
* For the bundled PostgreSQL: a default `StorageClass` (the database requests a 20Gi volume)
* For TLS: [cert-manager](https://cert-manager.io/) or an existing TLS Secret

> [!NOTE]
>
> The chart validates your values against a strict schema. Unknown keys, or a wrong type such as `--set ingress.tls=false`, fail the install with `values don't meet the specifications of the schema`. If you are coming from older docs or an older values file, see [Upgrading from chart 2.x](#upgrading-from-chart-2-x) and [Upgrading from chart 1.x](#upgrading-from-chart-1-x).

> [!WARNING]
>
> The PostgreSQL volume does not persist application files. Before a production install or any rollout of an existing deployment, configure [Kubernetes persistent storage](https://docs.bow.ai/kubernetes-persistent-storage.md). The guide covers AWS, Azure, other clusters, and copying files out of existing pods before replacing them.

### Quick start

**1. Add the Helm repository**

```bash theme={null}
helm repo add bow https://helm.bagofwords.com/
helm repo update
```

**2. Create a namespace and a Secret**

Keep sensitive values in a Kubernetes Secret, not in your values file. The chart loads every key of the Secret named in `config.secretRef` as an environment variable.

```bash theme={null}
kubectl create namespace bow

PG_PASSWORD=$(openssl rand -hex 24)

kubectl create secret generic bow-secrets -n bow \
  --from-literal=BOW_ENCRYPTION_KEY="$(openssl rand -base64 32 | tr '+/' '-_')" \
  --from-literal=password="$PG_PASSWORD" \
  --from-literal=postgres-password="$(openssl rand -hex 24)" \
  --from-literal=BOW_DATABASE_URL="postgresql://bow:${PG_PASSWORD}@bowapp-postgresql:5432/bagofwords"
```

* `BOW_ENCRYPTION_KEY` encrypts stored credentials. Back it up. See [Day 2 operations](https://docs.bow.ai/day-2-operations.md#the-encryption-key-is-the-most-important-thing-to-back-up).
* `password` and `postgres-password` are the keys the bundled PostgreSQL reads for the app user and the `postgres` superuser. The app user is created as the database owner, not as a superuser.
* `BOW_DATABASE_URL` points the app at the bundled database. The host is `<release-name>-postgresql`, so adjust it if your release is not called `bowapp`.

**3. Write a values file**

```yaml theme={null}
# values.yaml
host: bow.example.com

image:
  tag: "0.0.587"          # pin a version; the default is `latest`

postgresql:
  auth:
    username: bow
    database: bagofwords
    existingSecret: bow-secrets

ingress:
  className: nginx
  tls:
    enabled: true
    secretName: bowapp-cert
  annotations:
    cert-manager.io/cluster-issuer: letsencrypt-prod

config:
  secretRef: bow-secrets
  baseUrl: https://bow.example.com
```

**4. Install**

```bash theme={null}
helm upgrade -i bowapp bow/bagofwords -n bow -f values.yaml
```

**5. Verify**

```bash theme={null}
kubectl get pods -n bow
kubectl logs -n bow -l app.kubernetes.io/instance=bowapp --tail=100
```

The first start runs database migrations, so the pod can take a minute or two to become ready. Then open `https://bow.example.com`. Before DNS and TLS are in place, you can reach the app with:

```bash theme={null}
kubectl port-forward -n bow svc/bowapp 3000:3000
```

> [!WARNING]
>
> If neither `config.encryptionKey` nor `config.secretRef` is set, a new encryption key is generated each time a pod starts. Stored LLM keys, data source credentials and OAuth tokens then become unreadable after any restart. The chart prints a warning in its install notes when this happens.

> [!TIP]
>
> For a quick evaluation you can skip the Secret and pass values inline. These values are stored in plain text in the `ConfigMap`, and the chart warns about it:
>
> ```bash theme={null}
> helm upgrade -i bowapp bow/bagofwords -n bow --create-namespace \
>   --set host=bow.example.com \
>   --set postgresql.auth.username=bow \
>   --set postgresql.auth.password=<PG-PASS> \
>   --set postgresql.auth.database=bagofwords \
>   --set config.encryptionKey="$(openssl rand -base64 32 | tr '+/' '-_')"
> ```

### Database options

> [tab] Bundled PostgreSQL
>
> This is the default (`postgresql.enabled: true`). The [quick start](#quick-start) above uses it.
>
> The chart runs its own PostgreSQL 17 `StatefulSet` from the official `postgres` image, with a `<release>-postgresql` Service and a 20Gi volume. Common settings:
>
> ```yaml theme={null}
> postgresql:
>   enabled: true
>   auth:
>     username: bow
>     password: ""              # or use existingSecret
>     database: bagofwords
>     postgresPassword: ""      # superuser; empty generates one on first install
>     existingSecret: ""
>     secretKeys:
>       userPasswordKey: password
>       adminPasswordKey: postgres-password
>   primary:
>     persistence:
>       enabled: true
>       size: 50Gi
>       storageClass: ""        # empty uses the cluster default
>       existingClaim: ""
>     resources:
>       requests:
>         cpu: 100m
>         memory: 256Mi
>     nodeSelector:
>       workload: databases
>     tolerations: []
>     affinity: {}
>     extendedConfiguration: |
>       max_connections = 300
>       shared_buffers = 256MB
> ```
>
> Other keys: `postgresql.image.registry` / `repository` / `tag` (default `docker.io` / `library/postgres` / `17-bookworm`), `postgresql.imagePullSecrets`, `postgresql.primary.podAnnotations`, `podSecurityContext`, `containerSecurityContext` and `terminationGracePeriodSeconds` (`60`). Run `helm show values bow/bagofwords` for the full list. Keys from the old Bitnami subchart are rejected.
>
> > [!WARNING]
> >
> > Keep `postgresql.image.tag` on PostgreSQL 17 and the Debian 12 (`bookworm`) base. A major-version change needs a dump and restore or `pg_upgrade`, and a different base image can change text sort order and corrupt existing indexes.
>
> > [!NOTE]
> >
> > If you render the chart without cluster access (`helm template`, Argo CD, Flux), set `postgresql.auth.postgresPassword` or use `postgresql.auth.existingSecret`. Otherwise the chart cannot read the stored superuser password and writes a new random one to its Secret on every sync, while the database keeps the original.
>
> The chart sets `max_connections = 300` on the bundled server. The bundled database is a single instance. For production, prefer a managed database. See [Day 2 operations](https://docs.bow.ai/day-2-operations.md#back-up-the-database) for backups.

> [tab] External PostgreSQL
>
> Turn off the bundled database and supply the connection string through your Secret:
>
> ```bash theme={null}
> kubectl create secret generic bow-secrets -n bow \
>   --from-literal=BOW_ENCRYPTION_KEY="$(openssl rand -base64 32 | tr '+/' '-_')" \
>   --from-literal=BOW_DATABASE_URL="postgresql://bow:<PASSWORD>@db.example.com:5432/bagofwords"
> ```
>
> ```yaml theme={null}
> # values.yaml
> postgresql:
>   enabled: false
>
> config:
>   secretRef: bow-secrets
>   database:
>     # Optional. Only set this on PostgreSQL 14 or newer (see below).
>     idleSessionTimeoutMs: 600000
> ```
>
> Values loaded from `config.secretRef` take precedence over the chart's `ConfigMap`, so `BOW_DATABASE_URL` from the Secret is the one the app uses.

> [tab] AWS Aurora / RDS with IAM
>
> The app connects with short-lived IAM tokens instead of a stored password. Complete the [AWS setup](#aws-aurora-configuration) first, then:
>
> ```yaml theme={null}
> # aurora-values.yaml
> host: bow.example.com
>
> database:
>   auth:
>     provider: aws_iam
>     region: us-east-1
>     sslMode: verify-full
>     # Optional: custom CA bundle. Leave empty to use the AWS RDS CA bundle in the image.
>     sslRootCert:
>       secretName: ""
>       key: ""
>   host: my-cluster.cluster-xxx.us-east-1.rds.amazonaws.com
>   port: 5432
>   username: bow_user
>   name: bagofwords
>
> serviceAccount:
>   annotations:
>     eks.amazonaws.com/role-arn: arn:aws:iam::123456789012:role/bow-rds-role
>
> config:
>   secretRef: bow-secrets  # holds BOW_ENCRYPTION_KEY
>   baseUrl: https://bow.example.com
> ```
>
> ```bash theme={null}
> helm upgrade -i bowapp bow/bagofwords -n bow -f aurora-values.yaml
> ```
>
> With `database.auth.provider: aws_iam`, the chart does not deploy the bundled PostgreSQL, and the `postgresql.*` values are ignored.

> [!WARNING]
>
> `config.database.idleSessionTimeoutMs` sends PostgreSQL's `idle_session_timeout` parameter, which exists only on **PostgreSQL 14 and newer**. On older servers every connection fails. The chart turns it on (10 minutes) only for the bundled database. For an external or IAM-authenticated database, set it yourself only if you know the server is 14 or newer.

#### Connection pool sizing

Each Uvicorn worker keeps its own connection pool. The most connections BOW can open is:

```text theme={null}
(poolSize + maxOverflow) × uvicornWorkers × replicaCount
```

With the defaults (20 + 20 per worker, up to 4 workers per pod), one pod can open 160 connections. Keep the total under your server's `max_connections` minus its reserved superuser slots, or new connections are refused and agent runs fail. Tune it with:

```yaml theme={null}
replicaCount: 2
config:
  uvicornWorkers: "2"
  database:
    poolSize: 10
    maxOverflow: 10
postgresql:
  primary:
    extendedConfiguration: |
      max_connections = 300
```

### Ingress and TLS

| Value | Default | Description |
| - | - | - |
| `host` | `app.bagofwords.com` | Hostname for the Ingress rule. Always set this. |
| `ingress.enabled` | `true` | Create the Ingress. |
| `ingress.className` | `nginx` | IngressClass, for example `nginx`, `alb` or `traefik`. |
| `ingress.tls.enabled` | `false` | Add a TLS block for `host`. |
| `ingress.tls.secretName` | `bowapp-cert` | TLS Secret name. cert-manager creates it, or you create it yourself. |
| `ingress.annotations` | `{}` | Extra Ingress annotations. |

Also set `config.baseUrl` to the public URL (for example `https://bow.example.com`). BOW uses it to build links in emails and OAuth callback URLs.

**HTTP only, custom hostname:**

```bash theme={null}
helm upgrade -i bowapp bow/bagofwords -n bow -f values.yaml \
  --set host=bow.internal.example.com \
  --set ingress.tls.enabled=false \
  --set config.baseUrl=http://bow.internal.example.com
```

**TLS with cert-manager and larger uploads (nginx):**

```yaml theme={null}
ingress:
  tls:
    enabled: true
    secretName: bowapp-cert
  annotations:
    cert-manager.io/cluster-issuer: letsencrypt-prod
    nginx.ingress.kubernetes.io/proxy-body-size: "100m"
    nginx.ingress.kubernetes.io/proxy-read-timeout: "3600"
```

**Behind a proxy:** set `config.forwardedAllowIps` to your ingress controller's pod CIDR (comma-separated IPs or CIDRs). Without it, BOW sees every request as coming from the ingress controller and all anonymous users share one rate limit.

### Secrets

Values that start with `${` are placeholders. The chart does not put them in the `ConfigMap`, and BOW reads the real value from the environment at startup. Put the real values in the Secret named by `config.secretRef`, using these keys:

| Secret key | Used for |
| - | - |
| `BOW_ENCRYPTION_KEY` | Encryption of stored credentials. Required. |
| `BOW_DATABASE_URL` | Database connection string, when not using IAM auth. |
| `password`, `postgres-password` | Bundled PostgreSQL passwords, when `postgresql.auth.existingSecret` points to this Secret. |
| `BOW_LICENSE_KEY` | Enterprise license. You can also paste it in **Settings → License**. |
| `BOW_GOOGLE_CLIENT_SECRET` | Google OAuth client secret. |
| `BOW_OIDC_<NAME>_CLIENT_SECRET` | Client secret for each OIDC provider (any name works, as long as it matches the placeholder). |
| `BOW_LDAP_BIND_PASSWORD` | LDAP bind password. Secret only, the chart has no value for it. |
| `BOW_SMTP_PASSWORD` | SMTP password. |

If you set a sensitive value as plain text (for example `config.googleClientSecret=abc`), the chart stores it in the `ConfigMap` and prints a warning.

### Authentication

`config.authMode` controls sign-in: `hybrid` (default, local accounts and SSO), `local_only` or `sso_only`. Related flags: `config.allowUninvitedSignups`, `config.allowMultipleOrganizations` and `config.verifyEmails` (all `false` by default).

> [accordion] Google OAuth
>
> ```yaml theme={null}
> config:
>   googleOauthEnabled: true
>   googleClientId: "<client-id>.apps.googleusercontent.com"
>   googleClientSecret: "${BOW_GOOGLE_CLIENT_SECRET}"
> ```
>
> See [Google OAuth](#google-oauth) for the callback URL and scopes.

> [accordion] OIDC (Okta, Microsoft Entra ID, Auth0)
>
> ```yaml theme={null}
> config:
>   oidcProviders:
>     - name: entra
>       enabled: true
>       label: "Sign in with Microsoft"
>       icon: microsoft
>       issuer: https://login.microsoftonline.com/<tenant-id>/v2.0
>       clientId: "<client-id>"
>       clientSecret: "${BOW_OIDC_ENTRA_CLIENT_SECRET}"
>       scopes: ["openid", "profile", "email"]
>       pkce: true
>       clientAuthMethod: post     # basic | post
>       discovery: true
>       uidClaim: sub
>       syncGroups: true
>       groupClaim: groups
>       resolveGroupNames: true
> ```
>
> Optional per-provider keys: `redirectPath`, `extraAuthorizeParams` and `extraTokenParams`. See [OpenID Connect](#openid-connect-oidc) for provider-side setup.

> [accordion] LDAP / Active Directory
>
> ```yaml theme={null}
> config:
>   ldap:
>     enabled: true
>     url: ldaps://ad.corp.example.com:636
>     bindDn: CN=svc-bow,OU=Service Accounts,DC=corp,DC=example,DC=com
>     baseDn: DC=corp,DC=example,DC=com
>     organizationId: "<bow-organization-id>"
>     admissionGroupDn: CN=BOW Users,OU=Groups,DC=corp,DC=example,DC=com
>     useSsl: true
>     autoProvisionUsers: true
>     syncIntervalMinutes: 60
>     # caCertsFile: /etc/ldap-ca/ca.pem   # mount with extraVolumes
> ```
>
> Put `BOW_LDAP_BIND_PASSWORD` in the Secret. Other keys, with their defaults: `startTls` (`false`), `kerberosRealm`, `allowLocalSuperuserLogin` (`false`), `userSearchBase`, `userSearchFilter` (`(objectClass=person)`), `userEmailAttribute` (`mail`), `userNameAttribute` (`displayName`), `groupSearchBase`, `groupSearchFilter` (`(objectClass=group)`), `groupNameAttribute` (`cn`), `groupMemberAttribute` (`member`), `groupMemberFormat` (`dn` or `uid`), `connectionTimeout` (`10`) and `pageSize` (`500`).

### Email (SMTP)

```yaml theme={null}
config:
  smtp:
    enabled: true
    host: smtp.example.com
    port: 587
    username: bow
    password: "${BOW_SMTP_PASSWORD}"
    from_name: BOW
    from_email: bow@example.com
    use_tls: true
    use_ssl: false
    use_credentials: true
    validate_certs: true
```

Setting `config.smtp.host` also enables SMTP. If `enabled` is `true` and `host` is empty, the install fails. See [SMTP Settings](#smtp-settings) for what email is used for.

### Persistent storage for uploads

The chart does not mount a volume for uploaded files or branding assets. Without one, they are lost when a pod is rescheduled. Create a PersistentVolumeClaim and mount it:

```yaml theme={null}
extraVolumes:
  - name: uploads
    persistentVolumeClaim:
      claimName: bow-uploads
extraVolumeMounts:
  - name: uploads
    mountPath: /app/backend/uploads
    subPath: uploads
  - name: uploads
    mountPath: /app/backend/branding_uploads
    subPath: branding
```

If you run more than one replica, the claim must support `ReadWriteMany` (for example EFS, Filestore or Azure Files).

### Scaling and availability

```yaml theme={null}
replicaCount: 2
podDisruptionBudget:
  enabled: true
  minAvailable: "50%"
topologySpreadConstraints:
  - maxSkew: 1
    topologyKey: topology.kubernetes.io/zone
    whenUnsatisfiable: ScheduleAnyway
    labelSelector:
      matchLabels:
        app.kubernetes.io/instance: bowapp
autoscaling:
  enabled: false        # when true, replicaCount is managed by the HPA
  minReplicas: 2
  maxReplicas: 5
  targetCPUUtilizationPercentage: 80
```

Running more than one replica needs PostgreSQL and shared upload storage. Read [High availability](https://docs.bow.ai/day-2-operations.md#high-availability) before you scale out.

### Observability

Send traces to an OpenTelemetry collector:

```yaml theme={null}
config:
  otel:
    enabled: true
    serviceName: bagofwords-backend
    tracesEndpoint: http://otel-collector.observability:4317
    protocol: grpc         # grpc | http/protobuf
    headers: ""            # key1=value1,key2=value2
  telemetryEnabled: true   # anonymous product telemetry; set false to disable
```

### Other values

| Value | Default | Description |
| - | - | - |
| `image.registry` / `image.repository` / `image.tag` | `docker.io` / `bagofwords/bagofwords` / `latest` | App image. The pull policy is always `Always`, so pin `image.tag` in production. |
| `serviceAccount.name` | `bowapp` | ServiceAccount the chart creates and the pod uses. |
| `serviceAccount.imagePullSecret` | `""` | Pull secret for a private registry mirror. |
| `serviceAccount.annotations` | `{}` | For example IRSA (`eks.amazonaws.com/role-arn`) or GKE Workload Identity. |
| `resources.requests.cpu` / `.memory` | `2` / `900Mi` | App container requests. |
| `resources.limits.memory` | `""` | Memory limit. Empty means no limit. |
| `nameOverride` | `""` | Resource name. Defaults to the release name. |
| `strategy` | `{}` | Deployment strategy, for example `type: Recreate`. |
| `revisionHistoryLimit` | `2` | Old ReplicaSets to keep. |
| `terminationGracePeriodSeconds` | `30` | Time to drain requests on shutdown. Raise it (for example to `120`) so running agent turns can finish. |
| `nodeSelector`, `tolerations`, `affinity` | empty | App pod scheduling. Use `postgresql.primary.*` for the database pod. |
| `podAnnotations`, `podSecurityContext`, `containerSecurityContext`, `lifecycle` | empty | Passed through to the pod spec. |
| `initContainers`, `extraContainers` | `[]` | Init and sidecar containers. |
| `extraEnv`, `extraEnvFrom` | `[]` | Extra environment variables, or ConfigMaps and Secrets to load. |
| `extraVolumes`, `extraVolumeMounts` | `[]` | Extra volumes, for example uploads or a CA bundle. |
| `startupProbe`, `readinessProbe`, `livenessProbe` | HTTP `/health` on `3000` | Override probe settings. |
| `networkPolicy.enabled` / `.ingress` / `.egress` | `false` / `[]` / `[]` | Raw Kubernetes NetworkPolicy rules for the app pod. |

To see every value with its default:

```bash theme={null}
helm show values bow/bagofwords
```

### Update

With a pinned tag, change `image.tag` in your values file and run the same upgrade command:

```bash theme={null}
helm repo update
helm upgrade bowapp bow/bagofwords -n bow -f values.yaml
kubectl rollout status deployment/bowapp -n bow
```

If you use the default `latest` tag, restart the deployment to pull the newest image:

```bash theme={null}
kubectl rollout restart deployment -n bow -l app.kubernetes.io/instance=bowapp
kubectl rollout status deployment -n bow -l app.kubernetes.io/instance=bowapp
```

Migrations run automatically on startup and cannot be rolled back. Back up the database first. See [Upgrades](https://docs.bow.ai/day-2-operations.md#upgrades).

### Upgrading from chart 2.x

Chart `3.0.0` replaces the Bitnami PostgreSQL subchart with the chart's own PostgreSQL, running the official `postgres:17` image. Bitnami no longer publishes the image that chart 2.x used, so new 2.x installs cannot start their database.

Your data is reused in place. The StatefulSet name, volume (`data-<release>-postgresql-0`), on-disk layout and Secret keys are the same, so the upgrade restarts the database pod on the new image against the same volume. Expect about a minute of database downtime. There is no rollback to 2.x.

**1. Back up the database**

Snapshot the data volume or run `pg_dump`. This backup is your only way back if something goes wrong.

**2. Clean up your values file**

Remove Bitnami-only keys under `postgresql:`, such as `architecture`, `global`, `primary.resourcesPreset`, `metrics` and `readReplicas`. Also remove any `postgresql.image` override that points at `bitnami` or `bitnamilegacy`. The chart now rejects them.

If you changed the volume's storage class in 2.x (for example `primary.persistence.storageClass` or `global.storageClass`), set the same value under `postgresql.primary.persistence.storageClass`.

**3. Upgrade with the migration flag**

```bash theme={null}
helm repo update
helm upgrade bowapp bow/bagofwords -n bow -f values.yaml \
  --set postgresql.migrateFromBitnami=true
```

Without this flag, the upgrade stops on purpose while the release still runs the Bitnami database, so an unpinned pipeline cannot migrate your database unattended. You only need it once. Leaving it set afterwards is harmless.

> [!NOTE]
>
> The safety check needs cluster access, so it does not run under `helm template` or Argo CD. Pin the chart there (for example `targetRevision: 2.*`) until you are ready to migrate.

If the upgrade fails with `updates to statefulset spec for fields other than ... are forbidden`, delete only the StatefulSet object (the volume and data are kept) and upgrade again:

```bash theme={null}
kubectl delete statefulset bowapp-postgresql -n bow --cascade=orphan
```

If you used AWS IAM authentication on 2.x, the upgrade removes the unused Bitnami database. Its volume is kept, so delete it yourself once you are sure you don't need it.

### Upgrading from chart 1.x

If you are on 1.x, follow these steps first and then [Upgrading from chart 2.x](#upgrading-from-chart-2-x).

Chart `2.0.0` changes the Deployment's selector labels to `app.kubernetes.io/name` and `app.kubernetes.io/instance`. Kubernetes does not allow selectors to change, so a plain `helm upgrade` fails with `field is immutable: spec.selector`. Delete the Deployment, then upgrade. The app is unavailable until the new pod is ready. The database and its volume are not affected.

```bash theme={null}
kubectl delete deployment bowapp -n bow
helm upgrade bowapp bow/bagofwords -n bow -f values.yaml
```

With Argo CD, you can instead add the sync option `argocd.argoproj.io/sync-options: Replace=true` to the Deployment.

Also update your values file:

| 1.x | 2.0 and later |
| - | - |
| `ingress.tls: true` / `false` | `ingress.tls.enabled: true` / `false` |
| Logs selected with `-l app=bowapp` | `-l app.kubernetes.io/instance=<release>` (the `app` label still exists) |
| Unknown keys ignored | Unknown keys rejected by the schema |

### Uninstall

```bash theme={null}
helm uninstall bowapp -n bow
```

The bundled PostgreSQL volume (`data-bowapp-postgresql-0`) is kept. Delete it with `kubectl delete pvc` only if you no longer need the data.

## AWS Aurora Configuration

***

BOW supports connecting to **AWS Aurora PostgreSQL** using IAM database authentication. This eliminates static database passwords entirely — the application generates short-lived tokens (valid for 15 minutes) at connection time using AWS IAM.

### How it works

1. The app's service account assumes an IAM role (via IRSA in EKS, or instance profile on EC2)
2. On every new database connection, the app calls `generate_db_auth_token()` to get a temporary password
3. The token is used as the PostgreSQL password — established connections are not affected when it expires
4. SSL is required (`require` or `verify-full`)

### AWS Setup

**1. Enable IAM authentication on the Aurora cluster:**

```bash theme={null}
aws rds modify-db-cluster \
  --db-cluster-identifier <cluster-id> \
  --enable-iam-database-authentication \
  --apply-immediately
```

**2. Create the database user with IAM grants:**

```sql theme={null}
CREATE USER bow_user;
GRANT rds_iam TO bow_user;
GRANT ALL PRIVILEGES ON DATABASE postgres TO bow_user;
```

**3. Create an IAM policy allowing `rds-db:connect`:**

```json theme={null}
{
  "Version": "2012-10-17",
  "Statement": [
    {
      "Effect": "Allow",
      "Action": "rds-db:connect",
      "Resource": "arn:aws:rds-db:<REGION>:<ACCOUNT>:dbuser:<DB-RESOURCE-ID>/<DB-USER>"
    }
  ]
}
```

**4. For EKS — create an IAM role for the service account (IRSA):**

The Helm chart creates the `bowapp` ServiceAccount itself, so create only the IAM role with eksctl:

```bash theme={null}
eksctl create iamserviceaccount \
  --name bowapp \
  --namespace bow \
  --cluster <CLUSTER-NAME> \
  --role-name bow-rds-role \
  --attach-policy-arn arn:aws:iam::<ACCOUNT>:policy/bow-rds-connect \
  --role-only \
  --approve
```

Then set the role on the chart's ServiceAccount:

```yaml theme={null}
serviceAccount:
  annotations:
    eks.amazonaws.com/role-arn: arn:aws:iam::<ACCOUNT>:role/bow-rds-role
```

See the [AWS Aurora / RDS with IAM](#database-options) tab for the full Helm values.

### bow-config.yaml for Aurora (non-Kubernetes)

If you are running BOW on EC2 or ECS (not Kubernetes), you can configure Aurora IAM auth directly in `bow-config.yaml`:

```yaml theme={null}
database:
  host: "my-cluster.cluster-xxx.us-east-1.rds.amazonaws.com"
  port: 5432
  name: "postgres"
  username: "bow_user"
  auth:
    provider: "aws_iam"
    region: "us-east-1"
    ssl_mode: "require"
```

The EC2 instance or ECS task must have an IAM role with the `rds-db:connect` policy attached.

## Google OAuth

To enable Google OAuth authentication, configure the following parameters in your bow config (or in env/k8s configmap):

```yaml theme={null}
google_oauth:
  enabled: true
  client_id: ${BOW_GOOGLE_CLIENT_ID}
  client_secret: ${BOW_GOOGLE_CLIENT_SECRET}
```

You should also set the following in your Google OAuth configurations:

1. Callback URL: `https://yourbaseurl.com/api/auth/google/callback`
2. Scopes: `/auth/userinfo.email`, `/auth/userinfo.profile`, `openid`
3. Enable People API

## OpenID Connect (OIDC)

```yaml theme={null}
oidc_providers:
- name: okta
  enabled: true
  issuer: https://***********.okta.com/oauth2/default
  client_id: ${OKTA_CLIENT_ID}
  client_secret: ${OKTA_CLIENT_SECRET}
  scopes: ["openid", "profile", "email"]
  pkce: true
  client_auth_method: basic
  discovery: true
  uid_claim: sub
```

### For Okta

1. Set a new OIDC application: web
2. Set callback URL `https://your-base-bow-url.com/api/auth/okta/callback`

### For Microsoft Entra ID (Azure AD)

```yaml theme={null}
oidc_providers:
- name: entra
  enabled: true
  issuer: https://login.microsoftonline.com/${ENTRA_TENANT_ID}/v2.0
  client_id: YOUR_CLIENT_ID
  client_secret: ${BOW_ENTRA_CLIENT_SECRET}
  scopes: ["openid", "profile", "email", "api://YOUR_CLIENT_ID/access_as_user"]
  pkce: true
  client_auth_method: post
  discovery: true
  uid_claim: sub
  sync_groups: true
  group_claim: "groups"
  resolve_group_names: true
```

1. In the Azure portal, register a new application under **Microsoft Entra ID → App registrations**.
2. Set the redirect URI (type: Web) to `https://your-base-bow-url.com/api/auth/entra/callback`.
3. Copy the **Directory (tenant) ID** into `${ENTRA_TENANT_ID}` and the **Application (client) ID** into `${BOW_ENTRA_CLIENT_ID}`.
4. Under **Certificates & secrets**, create a client secret and set it as `${BOW_ENTRA_CLIENT_SECRET}`.
5. Use `client_auth_method: post` — Entra ID expects credentials in the request body rather than the `Authorization` header.

**Group sync**

When `sync_groups: true`, group memberships from the `groups` claim are synced to BOW on each login. To make Entra ID emit the claim, edit the app's **Token configuration → Add groups claim** and select the group types you want.

* `group_claim` — the token claim that carries group membership (default `groups`).
* `resolve_group_names: true` — by default Entra ID emits group **object IDs**, not display names. Enabling this resolves the IDs to human-readable names via Microsoft Graph (requires the app to have the `GroupMember.Read.All` or `Directory.Read.All` permission granted).

## SAML 2.0

For identity providers that require SAML, add a `saml_providers` entry. `base_url` must be an HTTPS origin.

```yaml theme={null}
saml_providers:
- name: company-sso
  enabled: true
  label: Company SSO
  organization_id: <existing-bow-organization-id>
  idp:
    metadata_url: https://idp.company.com/app/metadata
  attributes:
    subject: name_id
    email: mail
    name: displayName
```

Register `https://your-base-bow-url.com/api/auth/saml/company-sso/acs` as the Reply (ACS) URL and `https://your-base-bow-url.com/saml/company-sso` as the Entity ID. See [Sign-in and SSO](https://docs.bow.ai/sign-in-and-sso.md#saml-2-0) for trust options, admission rules, and troubleshooting.

## LDAP / Active Directory

For environments that authenticate against an on-premises directory, enable the LDAP provider:

```yaml theme={null}
ldap:
  enabled: true
  url: ldaps://ad.company.com:636
  bind_dn: cn=service-account,ou=Services,dc=company,dc=com
  bind_password: ${BOW_LDAP_BIND_PASSWORD}
```

* `url` — the directory endpoint. Use `ldaps://` (port 636) for TLS; avoid plain `ldap://` (port 389) outside a trusted network.
* `bind_dn` — the distinguished name of a read-only service account used to look up users.
* `bind_password` — the service account password, supplied via the `${BOW_LDAP_BIND_PASSWORD}` environment variable rather than committed to the config.

## SMTP Settings

Configure an outbound mail server to enable email features — dashboard and conversation share notifications, scheduled report and prompt results (with optional PDF attachments), password-reset and email-verification links, and the AI `send_email` tool.

Define the `smtp_settings` block in `bow-config.yaml`:

```yaml theme={null}
smtp_settings:
  host: "smtp.resend.com"
  port: 587
  username: "resend"
  password: ${BOW_SMTP_PASSWORD}
  from_name: "BOW"
  from_email: "hi@bagofwords.com"
  use_tls: true
  use_ssl: false
  use_credentials: true
  validate_certs: true
```

| Key | Default | Env var | Notes |
| - | - | - | - |
| `host` | `smtp.resend.com` | `BOW_SMTP_HOST` | SMTP server |
| `port` | `587` | `BOW_SMTP_PORT` | |
| `username` | — | `BOW_SMTP_USERNAME` | |
| `password` | — | `BOW_SMTP_PASSWORD` | Sensitive — supply via env var |
| `from_name` | `BOW` | `BOW_SMTP_FROM_NAME` | Display name on outgoing mail |
| `from_email` | `hi@bagofwords.com` | `BOW_SMTP_FROM_EMAIL` | From address |
| `use_tls` | `true` | `BOW_SMTP_USE_TLS` | STARTTLS |
| `use_ssl` | `false` | `BOW_SMTP_USE_SSL` | SSL at connect |
| `use_credentials` | `true` | `BOW_SMTP_USE_CREDENTIALS` | Set `false` for anonymous relays |
| `validate_certs` | `true` | `BOW_SMTP_VALIDATE_CERTS` | |

> [!NOTE]
>
> Email is enabled only when credentials are present (or `use_credentials: false` for anonymous relays). If no mail client can be built at startup, email features are silently inert — outgoing notifications are skipped and the `send_email` tool stays disabled.


This documentation is built and hosted on [Mintlify](https://mintlify.com), a developer documentation platform.