New to deployment? Start with Deployment Options, then come back here before you go live.
Production checklist
What to back up
Bag of Words keeps its state in five places. Back up all of them. A database backup alone is not enough to restore a working system.The encryption key is the most important thing to back up
- Generate the key once, set it explicitly and keep a copy in your secrets manager (Vault, AWS Secrets Manager, Kubernetes Secret with restricted access, and so on).
- Store it separately from your database backups. Anyone holding both can read your credentials.
- If you do not set it, the container generates one and writes it to
/app/backend/data/encryption.key. That path must be on a persistent volume, and the file must be backed up. On Kubernetes, always set the key through a Secret, because the chart does not provide storage for this file.
Back up the database
- Docker Compose
- Managed PostgreSQL
- Kubernetes (bundled PostgreSQL)
- Take at least a daily backup, and always take one immediately before an upgrade. Migrations run automatically on startup and there are no downgrade migrations, so a backup is your rollback path.
- Copy backups off the host or cluster, and keep several generations.
- Test a restore into a scratch environment on a schedule. An untested backup is not a backup.
- Do not use SQLite in production. It is a single file on a single node, and it prevents running more than one replica.
Back up files and configuration
- Snapshot or copy the Docker volumes
uploads_data,branding_dataandapp_data(Compose), or the equivalent persistent volumes. - Keep
bow-config.yaml,.env, the Compose file, theCaddyfileand your Helm values in version control. Keep secrets out of Git and in a secrets manager. - Do not forget the Caddy certificate volume (
caddy_data) if you want to avoid re-issuing TLS certificates after a rebuild.
On Kubernetes the Helm chart does not mount a volume for uploads or branding by default. Add them with
extraVolumes and extraVolumeMounts, otherwise uploaded files disappear when a pod is rescheduled.High availability
A single container is a single point of failure, and every deploy or restart interrupts users. For production we recommend two application replicas in front of a managed or highly available PostgreSQL.Requirements for running two replicas
1
Use PostgreSQL
Multiple replicas cannot share a SQLite file. Use PostgreSQL, ideally managed, with its own backups and failover.
2
Use the same encryption key everywhere
Every replica must receive an identical
BOW_ENCRYPTION_KEY. Without it each replica generates its own key and cannot read credentials written by the other, and sessions break between replicas.3
Share file storage
Uploaded files, the file cache and artifact storage live on local disk. Mount a shared (ReadWriteMany) volume for
/app/backend/uploads, and for BOW_ARTIFACT_STORAGE if you use it, so both replicas see the same files.4
Budget your database connections
Each worker process keeps its own connection pool. The ceiling is:The defaults are 20 + 20 per worker, and a replica runs 1 to 4 workers (
UVICORN_WORKERS). Stock PostgreSQL allows 100 connections, so make sure max_connections is above this ceiling, or lower the pool settings. If you exceed the limit you will see failed agent runs rather than a clear pool error.5
Put a load balancer in front
Use your ingress or load balancer to spread traffic across the replicas, and probe
GET /health. Chat responses stream over SSE, so disable response buffering and use a long idle timeout (at least a few minutes) on the proxy.6
Decide which replica runs background work
See the next section.
Scheduler and background listeners
Scheduled reports and prompts are stored in the database, and runs are de-duplicated across replicas through a shared claim table, so they are safe with several replicas. Some background work is elected per container, not per cluster: the email (IMAP) poller, the Slack Socket Mode listener, the Google Chat listener and a few housekeeping jobs. With two replicas each one elects its own leader, so these can run twice. If you use any of those channels, run them on exactly one replica:Rolling updates and in-flight work
- Agent runs execute inside the replica that received the request. When a replica is stopped, its in-flight runs end. Give pods a longer
terminationGracePeriodSecondsthan the 30 second default, and deploy during quiet hours. - If a browser reconnects to a different replica mid-run, it sees the last saved state of the run rather than the live stream.
- Every container applies database migrations when it starts. For releases that change the schema, avoid starting several new replicas at once. Scale to one replica (or use the
Recreatestrategy), let it migrate and become healthy, then scale back up. - On Kubernetes, enable the PodDisruptionBudget (
podDisruptionBudget.enabled=true) so node drains never take down both replicas, and spread replicas across nodes or zones.
Upgrades
1
Read the release notes
Review the release notes (also available in the app under What’s New) for breaking changes. Only the latest release receives security fixes, so plan to upgrade regularly.
2
Back up
Take a database backup and confirm your key and config are saved.
3
Test first
If you can, restore the backup into a staging environment and upgrade it first.
4
Upgrade to a pinned version
Change the image tag to the new version number. Do not rely on
latest, which can change underneath you on any restart.5
Verify
Check that
/health responds, you can sign in, a data source connects, and a simple question gets answered.- Pin versions. Images are published as
bagofwords/bagofwords:<version>,<version>-<date>andlatest. The Compose file and Helm chart default tolatestwith always-pull. Override this in production. - Migrations are automatic. On start, the container runs the database migrations and exits if they fail after three attempts. Because there is no downgrade path, rolling back means restoring the pre-upgrade database backup and redeploying the previous image tag.
- Helm chart 2.0.0 changed the Deployment selector labels. Upgrading from chart 1.x fails with
field is immutable: spec.selector. Delete the Deployment (kubectl delete deployment <release> -n <namespace>) and run the upgrade again. - Air-gapped installs upgrade by loading the new bundle’s images and running
docker compose up -d --pull never. See Air-gapped downloads.
Monitoring
Health checks
GET /health returns {"status":"ok"}. It confirms the process is running but does not check the database. Use it for liveness and load balancer probes, and add your own database monitoring alongside it. The container image and Helm chart already use it.
Logs
The application logs JSON lines to stdout (errors to stderr). Collect them with your platform’s log pipeline (Docker logging driver, Fluent Bit, Promtail, CloudWatch, and so on) and set a retention period. Nothing is written to log files inside the container.Traces
OpenTelemetry tracing is built in and off by default. Enable it inbow-config.yaml:
What to alert on
/healthfailing, or containers restarting repeatedly.- Database: connection count approaching
max_connections, disk usage, replication lag, failed backups. - Disk usage on the uploads and artifact volumes.
- Spikes in failed agent runs. Use Runs and traces to investigate.
- LLM spend and errors. Track them in Usage, cost and quality and set quotas.
- TLS certificate expiry, if you manage certificates yourself.
Audit logs
Audit logs record administrative activity. On the enterprise plan you can stream them to your SIEM (Datadog, Splunk, Microsoft Sentinel, S3, GCS, a webhook or syslog) so they are retained outside the application database.Security hardening
- HTTPS everywhere. Keep Caddy or your ingress terminating TLS, and set
base_urlto the publichttps://URL. It is used for SSO redirects, and SAML requires HTTPS. - Change default credentials. Replace the sample PostgreSQL password (
bowpasswordin the Compose file) and restrict who can reach the database port. Do not publish PostgreSQL to the internet. - Keep secrets out of config files. Reference them as
${VAR}inbow-config.yamland inject them from Docker secrets, a Kubernetes Secret (config.secretRef) or your secrets manager. - Set
FORWARDED_ALLOW_IPScorrectly. Behind a proxy or ingress, the default (127.0.0.1) makes every visitor look like the same client, which breaks per-client rate limiting. Set it to your proxy’s address. - Use SSO. Configure SSO, set
auth.modetosso_onlyif you want to disable local passwords, and review who has admin roles. Disable open sign-ups withallow_uninvited_signups: false. - Limit network access. Enable the chart’s NetworkPolicy and allow outbound traffic only to your LLM providers, data sources and identity provider. Use
HTTPS_PROXYandNO_PROXYif egress goes through a proxy. - Least-privilege data access. Give each data source a read-only database user, and use row-level security where users should see different data.
- Telemetry. Anonymous product telemetry is enabled by default. Set
telemetry.enabled: falseinbow-config.yaml(Helm:config.telemetryEnabled=false) if your policy requires it, and always in air-gapped environments. - Scan and patch. Pull updated images regularly, scan them in your registry, and patch the host and PostgreSQL on a schedule.
Capacity and cost
- Size for concurrency. Each worker runs up to
BOW_MAX_CONCURRENT_AGENTSagent runs at once (default 12), and extra runs queue. Watch CPU and memory under real load and adjust workers (UVICORN_WORKERS) and resources. The Helm chart requests 2 CPUs and 900Mi by default and sets no memory limit. - Control LLM spend. Set usage quotas, and review cost regularly. Keep a second model configured, or use fallback on the enterprise plan, so an outage at one LLM provider does not stop your users.
- Protect your data sources. Add rate limits (enterprise) or use a read replica for analytics so heavy questions cannot slow your production systems.
- Retention. Step payloads are purged automatically (14 days by default). Plan for database growth from reports, traces and audit logs, and monitor disk space.
Routine operations calendar
Disaster recovery in short
- Provision a new environment and deploy the same image version you were running.
- Restore the PostgreSQL backup.
- Restore the file volumes (
uploads,branding_uploads, artifact storage). - Supply the original
BOW_ENCRYPTION_KEYand yourbow-config.yaml. - Start the application, point DNS at it, and verify sign-in, a data source and a test question.
