# Deploy Kanera with Docker Most deployments should use the prebuilt images. Every release publishes them to GHCR (`ghcr.io/happendev/kanera-api`, `kanera-web`, `kanera-mcp`, `kanera-db-backup`) and attaches a standalone `docker-compose.yml` that pulls them, so the server needs no checkout and no build: ```bash mkdir kanera && cd kanera curl -fsSLo docker-compose.yml https://github.com/happendev/Kanera/releases/latest/download/docker-compose.yml curl -fsSLo .env https://github.com/happendev/Kanera/releases/latest/download/env.example # edit .env, then: docker compose up -d ``` To update, download the new release's `docker-compose.yml` over the old one and run `docker compose pull && docker compose up -d`. The standalone file is generated from this repository's `docker-compose.yml` by `scripts/build-release-compose.mjs`; it leaves out the monitoring profile, which needs the config files under `docker/monitoring/`. The rest of this guide covers building from a checkout of this repository, which you need only to run modified code or the monitoring stack. Every section except the build and upgrade commands applies to both. For Dokploy, use [DOKPLOY_DEPLOY.md](DOKPLOY_DEPLOY.md). ## Requirements - Docker with Docker Compose 2.23 or newer (`docker compose version`) - A domain name pointing at your server - HTTPS in front of the web container, usually with Caddy, Traefik, nginx, or a cloud load balancer - An SMTP account if you want Kanera to send email ## Services The compose file starts seven services: | Service | Purpose | |---|---| | `postgres` | PostgreSQL 18 with persistent data in `kanera_pgdata`. | | `valkey` | Required Valkey instance for Socket.IO fanout, presence, and shared rate limits. | | `api` | Main Kanera API and realtime server. Internal port `3000`; safe to scale. | | `worker` | Single background worker for schedulers, realtime outbox fallback fanout, webhooks, and presence reaping. Internal port `3003`. | | `public-api` | Public integration API. Internal port `3001`. | | `mcp` | MCP Streamable HTTP server backed by the public API. Internal port `3002`. | | `web` | Built Angular app served by nginx. Internal port `80`; proxies `/api/*` and `/socket.io/*` to `api`. | The browser should connect to the `web` service. The main `api` service should stay internal. Expose `public-api` only if you want the external integration API. Expose `mcp` only if you want remote MCP clients to connect to Kanera over HTTP. Its body-size, per-client rate, and HTTP timeout limits are configurable with the `MCP_*` settings in `.env.full.example`. Set `MCP_TRUST_PROXY=true` when it is routed through a trusted reverse proxy. ## Replica count Valkey is required for every deployment. The `api` service uses the Socket.IO Redis adapter against Valkey, Valkey-backed presence, and Valkey-backed auth rate limits, so multiple API processes share the same realtime and abuse-control state. Deployments run the default shape with two `api` replicas: ```bash docker compose up -d ``` Set `API_REPLICAS` in `.env` before deploying to change how many app API processes Compose keeps running across restarts and redeploys: ```env API_REPLICAS=4 ``` Schema migrations run once in a dedicated one-shot `migrate` service, not in the API replicas. `api`, `worker`, and `public-api` wait for it to finish (`service_completed_successfully`) before starting, so scaling `api` never races migrations and no service serves traffic before the schema is ready. On a deploy with no pending migrations the `migrate` container is a fast no-op. Keep exactly one `worker` service. It runs all schedulers, webhook delivery, presence crash reaping, and the realtime outbox dispatcher. The `api` service handles HTTP and Socket.IO only; it does not run background jobs. The `public-api` service does not host Socket.IO and can still be scaled separately when needed. ## Performance tuning Each `api`, `public-api`, and `worker` service is a Node.js process with one main event loop. Scale `api` replicas on larger machines to spread HTTP and realtime load across cores; leave `worker` single-instance so schedulers and dispatchers do not duplicate work. The defaults below are conservative; on a dedicated server these are the knobs worth raising. The defaults are safe and need no change for small deployments. The compose file passes these through, so set them in `.env` to override. **Database connection pools (`PG_POOL_MAX`, Compose default `20`; `WORKER_PG_POOL_MAX`, default `5`).** This caps how many queries each process runs against Postgres at once and is usually the first ceiling under write-heavy load. Keep the total comfortably below Postgres `max_connections`: ```text (API_REPLICAS × PG_POOL_MAX) + (public-api replicas × PG_POOL_MAX) + WORKER_PG_POOL_MAX + maintenance margin ``` With the bundled Compose defaults, that starts as: ```text (API_REPLICAS × 20) + (public-api replicas × 20) + WORKER_PG_POOL_MAX + maintenance margin ``` `API_REPLICAS` is the first term, so raising it raises the connection total linearly. When you scale `api` up, raise Postgres `max_connections` (or lower `PG_POOL_MAX`) to keep this sum comfortably under the server limit. **API response compression.** The bundled `web` container gzips proxied `/api/` and `/public-api/` JSON responses (`apps/web/nginx.conf`). This is the largest single bandwidth lever in the stack: opening a 1,000-card board is ~1.6 MB uncompressed and ~120 KB gzipped. Compression runs in nginx rather than Node so it does not consume the API event loop. If you terminate requests somewhere other than the bundled `web` container — an external load balancer, an ingress controller, or a CDN that talks straight to `api:3000` — enable gzip or brotli for `application/json` there too, or that saving is lost. **File descriptors (`ulimits.nofile`).** The realtime server holds one file descriptor per connected WebSocket. The compose file already raises the `api` container limit to ~1,000,000; the default of 1024 would otherwise cap you near a thousand concurrent clients. If you run the API outside the bundled compose file, set an equivalent `LimitNOFILE` (systemd) or `--ulimit nofile` (docker run). **Node heap (`NODE_OPTIONS`).** Node sizes its old-space heap from available memory, which can behave unpredictably on a very large host. Pin it explicitly, for example `NODE_OPTIONS=--max-old-space-size=8192` for an 8 GB heap. A few GB is plenty for tens of thousands of mostly-idle WebSocket connections; raise it only if heap-usage logs or GC pauses say you need to. **libuv worker pool (`UV_THREADPOOL_SIZE`, default `16` in compose).** Native work such as Sharp, Argon2, and crypto uses libuv worker threads. This is a separate environment variable, not a `NODE_OPTIONS` flag. **Postgres memory.** The bundled `postgres` container ships with stock settings. On a dedicated box, give it real cache. Add a `compose.override.yml`: ```yaml services: postgres: command: - "postgres" - "-c" - "shared_buffers=8GB" - "-c" - "effective_cache_size=24GB" - "-c" - "max_connections=200" ``` Size `shared_buffers` at roughly 25% of the RAM you want Postgres to use and `effective_cache_size` higher to reflect OS page cache. Keep `max_connections` above the pool math above plus room for backups and maintenance. ## 1. Create your environment file With the release images, download the example file next to `docker-compose.yml`: ```bash curl -fsSLo .env https://github.com/happendev/Kanera/releases/latest/download/env.example ``` From a checkout, copy it instead: `cp .env.example .env`. The minimal template contains only settings required to start Compose. Use `.env.full.example` (attached to each release as `env.full.example`) as the reference for every optional setting and default. Edit `.env` and set these production values: ```bash WEB_ORIGIN=https://kanera.example.com COOKIE_DOMAIN=kanera.example.com COOKIE_SECURE=true KANERA_ENVIRONMENT=production POSTGRES_PASSWORD= JWT_SECRET= MFA_ENCRYPTION_KEY= MEDIA_SIGNING_SECRET= SECRETS_ENCRYPTION_KEY= ``` `POSTGRES_PASSWORD` is required by the bundled Compose database. Use a URL-safe value because Compose also inserts it into the application services' internal `DATABASE_URL` values. Optional default SMTP settings: ```bash SMTP_HOST=smtp.example.com SMTP_PORT=587 SMTP_SECURITY=starttls SMTP_USER=kanera@example.com SMTP_PASSWORD=your-password SMTP_FROM_EMAIL=kanera@example.com SMTP_FROM_NAME=Kanera SMTP_IDENTITY_DOMAIN=example.com INTERNAL_NOTIFICATION_EMAILS=ops@example.com,founder@example.com LIFECYCLE_FEEDBACK_EMAIL=support@kanera.app ``` Organisation admins can override SMTP later from Kanera's organisation settings. ### Management portal The management portal (`apps/admin-web` + the `admin-api` process) is a separate, cross-tenant admin console for platform staff: org/user administration, ops visibility, and support access. It is bundled in the API image but disabled by default. Enable its Compose profile and route port 3002 to the admin domain: ```bash COMPOSE_PROFILES=admin ``` The portal has its own identity domain, isolated from tenant accounts: ```bash ADMIN_JWT_SECRET= # must differ from JWT_SECRET ADMIN_WEB_ORIGIN=https://admin.kanera.example.com # Optional: set when the admin hostname is outside COOKIE_DOMAIN, e.g. # COOKIE_DOMAIN=kanera.example.com and ADMIN_WEB_ORIGIN=https://kanera-admin.example.com. ADMIN_COOKIE_DOMAIN= ``` **First superadmin.** There is no manual insert step. On every boot, the admin server seeds exactly one `superadmin` from `ADMIN_EMAIL`/`ADMIN_PASSWORD` if the `admin_user` table is still empty; once any admin account exists, this is a permanent no-op regardless of what those env vars still hold. The worker also sends a system recap to `ADMIN_EMAIL` every Monday at 07:00 UTC. Set both values before the admin-api's first boot: ```bash ADMIN_EMAIL=ops@example.com ADMIN_PASSWORD= ``` If the table is empty and these are unset, the admin server boots with a locked-out console (log a warning) rather than failing to start. After the first superadmin exists, invite further admins from inside the console's Admins page rather than through env vars — that flow emails an accept-invite link scoped to `ADMIN_WEB_ORIGIN`. The superadmin-only **Demo data** page can create or reset the bundled paid demo tenants. Compose gives `admin-api` the same `kanera_uploads` volume and S3 settings as the tenant API; preserve that shared storage wiring in custom deployments so seeded avatars, covers, thumbnails, and attachments are both served correctly and fully purged on reset. Each reset rotates to a cryptographically generated password that is displayed once in the portal. ### Support access Cross-tenant support access is started from the management portal. A portal admin with the `superadmin` role opens an org in the admin console and starts a support session (`POST /admin/orgs/:clientId/support-session` with a `reason`), which mints a short-lived token that acts as the target org's owner and returns a `WEB_ORIGIN/support/enter#token=…` link. Every start is recorded in both the `support_session` audit table and `admin_audit_log`. Tune the token lifetime with `SUPPORT_SESSION_TTL_MINUTES` (default 60); there is no refresh companion, so the session expires on its own and can be revoked from the portal. The admin server signs this tenant token, so it must be given `JWT_SECRET` (in addition to its own `ADMIN_JWT_SECRET`) and `WEB_ORIGIN`. ``` SUPPORT_SESSION_TTL_MINUTES=60 ``` Hosted mode is reserved for Kanera-operated deployments and is unavailable in self-host installations. GitHub private repository previews do not need deployment-time GitHub secrets. After deploy, an organisation admin can open **Settings -> Organisation -> GitHub App**, enter the GitHub organisation login, and let Kanera create the GitHub App through GitHub's manifest flow. The generated app credentials and installation id are stored encrypted in the database. Optional operational alerts for deployment readiness, unhandled API errors, billing reconciliation failures, and failed or stuck organisation deletions. (Slow requests are written to the "slow request" log and shipped to Loki for per-request drill-down; aggregate latency is alerted on by Grafana's p95 rule, so there is no separate slow-request webhook.) A single **Slack-compatible** incoming webhook — Slack, Zulip (its `slack_incoming` integration), Mattermost, and Discord all work: ```bash ALERT_WEBHOOK_URL=https://hooks.slack.com/services/YOUR_WEBHOOK_PATH # or a Zulip slack_incoming webhook: # ALERT_WEBHOOK_URL=https://zulip.example.com/api/v1/external/slack_incoming?api_key=XXXXXXXXXXXXXXXX&stream=alerts&topic=Kanera OPS_ALERTS_ENABLED=true OPS_ALERT_THROTTLE_MS=300000 SLOW_REQUEST_LOG_MS=2500 ``` This same webhook is reused by Grafana's alerts when the monitoring stack is enabled — no separate alerting config. Compose sends one readiness message after every core service health check passes. Individual service processes do not send startup messages, including when `api` is scaled. Build, migration, or health-check failures should use the deployment platform's failure notification; runtime API errors continue to use this webhook directly. ## 2. Publish ports The default compose file keeps services private. If you are running only Docker on the server, add a `compose.override.yml` file: ```yaml services: web: ports: - "8080:80" public-api: ports: - "3001:3001" mcp: ports: - "3002:3002" ``` Then point your HTTPS reverse proxy at: - `http://127.0.0.1:8080` for the Kanera web app - `http://127.0.0.1:3001` for the public API, if you use it - `http://127.0.0.1:3002` for the MCP server, if you use it The app API has Valkey-backed per-IP auth limits for login, signup, and password reset. Set `API_TRUST_PROXY=true` when Node is behind a trusted reverse proxy such as nginx, Traefik, or an ingress that sends the real client IP; leave it `false` only when Node is directly internet-facing or directly Cloudflare-facing. Without this, a non-Cloudflare proxy collapses all users into the proxy socket IP and the auth limit can lock out legitimate users. The public API has Valkey-backed rate limits for helper routes, API-key requests, and attachment uploads. Set `PUBLIC_API_TRUST_PROXY=true` only when the service is behind a trusted reverse proxy that sends the real client IP. When Cloudflare is the direct peer, Kanera uses `CF-Connecting-IP` for IP rate-limit buckets after verifying the peer is in Cloudflare's published IP ranges. Do not expose the `api` service directly. The `web` nginx container already forwards app API and Socket.IO traffic. If you expose `mcp`, route `/mcp`, `/.well-known/oauth-protected-resource`, and `/.well-known/oauth-protected-resource/mcp` to the `mcp` service and require TLS. Set `MCP_SERVER_PUBLIC_URL` to that public `/mcp` URL and `PUBLIC_API_OAUTH_ISSUER` to the browser-reachable public API origin. Route the issuer's `/.well-known/oauth-authorization-server` and `/oauth/*` paths to `public-api`. Interactive MCP clients then sign in through Kanera OAuth; legacy `Authorization: Bearer kanera__...` API keys continue to work. Set `KANERA_ENVIRONMENT=staging` on staging so newly generated API keys use `kanera_stg_`; production uses `kanera_live_`. ## 3. Start With the release images: ```bash docker compose up -d ``` From a checkout, build the images first with `docker compose up -d --build`. The dedicated one-shot `migrate` service applies pending database migrations. The `api`, `worker`, and `public-api` services start only after it completes successfully; never add migration commands to scaled application replicas because concurrent DDL runs can race. The `mcp` service depends on `public-api` and starts after the public API health check passes. ## 4. Check the deployment Open your web domain in a browser. Useful checks: ```bash docker compose ps docker compose logs -f api docker compose logs -f mcp curl https://kanera.example.com/api/health curl https://api.kanera.example.com/health curl https://mcp.kanera.example.com/health ``` The public API health check only works if you expose `public-api`. The MCP health check only works if you expose `mcp`. ## Upgrading With the release images, from the directory holding `docker-compose.yml` and `.env`: ```bash curl -fsSLo docker-compose.yml https://github.com/happendev/Kanera/releases/latest/download/docker-compose.yml docker compose pull docker compose up -d ``` Use `releases/download/vX.Y.Z/docker-compose.yml` instead of `releases/latest/download/...` to move to a specific release. Releases can be skipped: the one-shot `migrate` service applies every pending migration in order before the API, worker, and public API start. From a checkout, check out the release tag and rebuild: ```bash git fetch --tags git checkout vX.Y.Z docker compose up -d --build ``` Back up first (see [Backups](#backups)) and read the release notes for every release between the running version and the target. `docker compose images api` shows the running version. Rolling back means restoring the pre-update database: an older release cannot run against a schema a newer one has migrated. Keep the previous `docker-compose.yml` (or tag) until the update is verified. Postgres, Valkey, and the monitoring images are pinned to exact patch versions in `docker-compose.yml`, so a routine update leaves them alone unless the release itself moved a pin. That is deliberate: a routine code deploy should not restart your database. Those versions move when a Dependabot image PR is merged and released. With the release images, `docker compose up -d` after downloading that release's compose file recreates the affected service automatically. From a checkout, recreate it explicitly after pulling the change: ```bash docker compose up -d postgres ``` Data lives in the `kanera_pgdata` volume, not in the container, so recreating the Postgres container preserves the database. Postgres *minor* upgrades (18.6 to 18.7) are safe in place; a *major* upgrade (18 to 19) requires `pg_upgrade` or a dump-and-restore and is never proposed automatically. ## Backups For the bundled Postgres container: ```bash docker compose exec -T postgres pg_dump -U kanera kanera | gzip > kanera-$(date +%F).sql.gz ``` The database lives in the `kanera_pgdata` Docker volume. Uploaded files live in the `kanera_uploads` Docker volume. Back up both. Deployments can also enable automated full Postgres backups to S3. The backup worker creates a full database dump three times per day, compresses it with gzip, encrypts it with GPG symmetric AES-256, uploads it to S3, and keeps a rolling 14-day window: ```bash DB_BACKUPS_ENABLED=true DB_BACKUP_ENCRYPTION_PASSPHRASE= DB_BACKUP_TIMES_UTC=00:15,12:15,16:45 DB_BACKUP_RETENTION_DAYS=14 DB_BACKUP_S3_PREFIX=backups/postgres ``` By default the backup worker reuses `S3_BUCKET`, `S3_REGION`, `S3_ACCESS_KEY_ID`, `S3_SECRET_ACCESS_KEY`, and `S3_ENDPOINT`. Set the matching `DB_BACKUP_S3_*` variables if backups should use a different bucket, endpoint, region, or credentials. The generated objects are full backups named like `backups/postgres/kanera-2026-06-10-0215.sql.gz.gpg`. ## Monitoring Every app process (`api`, `worker`, `public-api`) exposes a Prometheus `/metrics` endpoint with HTTP request latency (by route and status), database query latency, Postgres connection-pool saturation, and Node runtime metrics (event loop lag, heap, GC). This is on by default, but the endpoint fails closed with `404` until a bearer token is configured: ```bash METRICS_TOKEN= ``` The token applies uniformly to every app process. Prometheus refuses to start without it when the monitoring profile is enabled. ### Optional: full Grafana stack A self-hosted **Prometheus + Grafana + Loki + Alloy** stack plus exporters (`postgres-exporter`, `cadvisor`, `node-exporter`, `redis-exporter`) ships in the same compose file behind the `monitoring` profile, so it is off unless you opt in. Budget roughly **0.7–1.5 GB RAM** for it. Enable it in `.env`: ```bash COMPOSE_PROFILES=monitoring GRAFANA_ADMIN_PASSWORD= METRICS_TOKEN= ALERT_WEBHOOK_URL= # Set explicitly if your platform chooses a different Compose project name. # Alloy uses this to filter Docker log discovery to this deployment only. # On Dokploy the name is auto-generated (e.g. `kanera-app-ggcqgp`) — run # `docker compose ls` on the server to find the real value. COMPOSE_PROJECT_NAME=kanera # Reach the dashboards over an internal WireGuard VPN (recommended, see below). MONITORING_BIND_IP=172.30.0.102 # Optional: PROMETHEUS_RETENTION=30d ``` The monitoring services and their config files under `docker/monitoring/` are only in the repository, not in the standalone release compose file, so run this from a checkout of the release tag: ```bash docker compose --profile monitoring up -d --build ``` Grafana packages provisioning and bundled dashboards in its image. They stay outside the `/var/lib/grafana` data volume so deployments can replace the checkout without leaving stale directory mounts; rebuild Grafana when changing those files. What you get: - **Prometheus** scrapes all app replicas (via Docker DNS service discovery) and the exporters. Internal-only. - **Grafana** with provisioned Prometheus + Loki datasources and a bundled "Kanera — App (RED + DB)" dashboard. Import community dashboards by ID for more depth: Node Exporter Full `1860`, PostgreSQL `9628`, and a cAdvisor dashboard. - **Loki + Alloy** aggregate the app's Pino JSON logs so you can search by `requestId`, `userId`, route, level, and status in Grafana → Explore. Alloy filters discovery by `COMPOSE_PROJECT_NAME`, excluding unrelated containers on a shared host. - **Grafana alerting** reuses the same `ALERT_WEBHOOK_URL` as the app (no separate config), delivered via Grafana's Slack integration (Slack and every Slack-compatible endpoint — Zulip's `slack_incoming`, Mattermost, ... — accept it). Starter rules (review and tune thresholds in the Grafana UI): - **App:** 5xx ratio, p95 latency, Postgres pool saturation, app service down, and emails that failed permanently (SMTP rejected the recipient or retries ran out). - **Meta-monitoring:** any monitoring/infra target down (exporters, Grafana, Loki, Alloy, Prometheus, DB, cache), **host disk space low**, Prometheus config-reload failure, Prometheus TSDB compaction failure, Loki ingestion (5xx) errors, and log entries Loki rejected (4xx: too old, out of order, rate limited) or Alloy dropped. - Alloy drops log lines older than 1h before pushing them, so a replay after a lost read position doesn't flood Loki with rejected writes. Keep the `kanera_alloy_data` volume (don't `down -v` the monitoring stack) so positions survive restarts. The app replica alert compares healthy targets with `API_REPLICAS`, `PUBLIC_API_REPLICAS`, and one worker, and fires after a shortfall lasts three minutes. Temporary extra targets during a deployment do not raise the expected count. Change replica counts through these Compose variables (rather than an ad-hoc `--scale`) so monitoring and the deployment agree. > **Disk safety.** Prometheus is bounded by `PROMETHEUS_RETENTION_SIZE` (a real disk > cap). Loki's filesystem store has **no** hard size cap — retention deletes by age on > a schedule, so a burst can fill the disk before compaction runs > ([Grafana docs](https://grafana.com/docs/loki/latest/configure/storage/)). The Loki > config sets ingestion rate limits and frequent compaction to bound growth, and the > **host disk-space alert** is the backstop. For high log volume, put Loki on a > dedicated/size-capped partition or object storage. All monitoring volumes live on the > host root filesystem by default, so a separate data disk is the cleanest hard limit. > **Self-monitoring blind spot.** Prometheus cannot alert that *it* is down (it can't > scrape itself when dead), and Grafana cannot send an alert that *it* is down. The > meta-monitoring rules catch every other component, but the watchers themselves need an > **external** check — an uptime monitor hitting Grafana/Prometheus, or a dead-man's > switch (have Prometheus push a heartbeat to an external service that pages if it stops). **Access over WireGuard (recommended).** Rather than put dashboards on the public internet, publish Grafana and Prometheus only on the server's WireGuard (`wg0`) interface. Unlike the monitoring UIs, bundled Postgres is always bound to host loopback. Set `MONITORING_BIND_IP` to the server's `wg0` address: ```bash MONITORING_BIND_IP=172.30.0.102 # the server's wg0 address # Optional if these host ports are taken: GRAFANA_BIND_PORT=3000 PROMETHEUS_BIND_PORT=9090 ``` Then from a machine on the VPN, browse to `http://172.30.0.102:3000` (Grafana) and `http://172.30.0.102:9090` (Prometheus). Until `MONITORING_BIND_IP` is set both bind to `127.0.0.1` only. **Never set it to `0.0.0.0` in production**, and add a host firewall rule allowing ports `3000`/`9090` on `wg0` only. Loki, Alloy, and the exporters stay internal-only and are reached through Grafana. Alternatively, leave `MONITORING_BIND_IP` unset and put Grafana (container port `3000`) behind your HTTPS reverse proxy with auth; keep Prometheus, Loki, Alloy, and the exporters internal-only. ### pg_stat_statements `pg_stat_statements` (top queries by total/mean time and call count — the best tool for finding slow queries) is enabled on the bundled Postgres via `shared_preload_libraries`. On a **brand-new** volume the extension is created automatically by `docker/postgres/init.sql`. On an **existing** database the init script does not re-run, so after upgrading create it once: ```bash docker compose exec -T postgres psql -U kanera -d kanera \ -c "CREATE EXTENSION IF NOT EXISTS pg_stat_statements;" ``` Then inspect slow queries directly: ```bash docker compose exec -T postgres psql -U kanera -d kanera -c \ "select calls, round(mean_exec_time::numeric,2) as mean_ms, query from pg_stat_statements order by total_exec_time desc limit 15;" ``` The **Postgres Query Performance** dashboard reads the same data through `postgres-exporter`. That exporter connects as `kanera_exporter`, a dedicated role holding only `pg_monitor` rather than the application role's full access. The `postgres-exporter-init` service creates and updates that role from `docker/monitoring/exporter-role.sql` every time the monitoring profile starts, so enabling monitoring on an existing database needs no manual SQL — but note the exporter will not start until that one-shot service has completed successfully. Query text on that dashboard is truncated to 200 characters, and the exporter's own scrape queries are excluded by role so they cannot crowd out application traffic. DDL and migration statements are filtered out in the dashboard queries; use the `psql` command above when you need to see those. ## Environment Reference ### Product analytics (Kanera Cloud only) Compose forwards the analytics configuration to the app API, public API and worker. Recreate all three services after changing it. Consented first-touch acquisition is stored privately in `user_acquisition`; only allow-listed campaign dimensions, never advertising click IDs, accompany PostHog events. Apply migrations before deploying the API. See [the analytics launch handoff](docs/analytics-launch.md) for the rollout and verification checklist. Product analytics is disabled by default, including all self-hosted installations. Kanera Cloud may enable explicit PostHog events with `ANALYTICS_ENABLED=true`, `ANALYTICS_PROVIDER=posthog`, `POSTHOG_PROJECT_KEY`, and `POSTHOG_API_HOST`. Use separate EU projects for production and staging; the project key is the public capture token, not a personal API key. Capture is explicit and content-free: identifiable user and owner details are profile-only, while autocapture, replay, heatmaps, input capture, and work-item content remain excluded. Before production, publish the privacy notice, complete the lawful-basis/cookie assessment and DPA, restrict access, set 12-month retention, and verify deletion/export procedures. Set `client.analytics_excluded=true` for staff, demo, seed, test, and load-test organisations. | Variable | Required | Notes | |---|---|---| | `WEB_ORIGIN` | yes | Public browser origin, for example `https://kanera.example.com`. | | `COOKIE_DOMAIN` | yes | Cookie domain, usually the app hostname. | | `COOKIE_SECURE` | yes | Use `true` for HTTPS deployments. | | `JWT_SECRET` | yes | Stable random secret. Rotating it signs users out. | | `MFA_ENCRYPTION_KEY` | yes | Stable, dedicated key for encrypted TOTP secrets and MFA challenges. Rotating it invalidates enrolled authenticators. | | `MEDIA_SIGNING_SECRET` | yes | Stable random secret for signed media URLs. | | `SECRETS_ENCRYPTION_KEY` | recommended | Stable random secret for encrypted stored secrets. | | `KANERA_ALLOW_PRIVATE_NOTIFICATION_DESTINATIONS` | no | Self-hosted only. Set `true` to let personal ntfy, Gotify, and webhook channels use HTTP/private-network receivers. Defaults to `false`; metadata, link-local, reserved destinations, and redirects stay blocked. | | `ADMIN_JWT_SECRET` | required to run the admin-api | Stable random secret for the management portal's own sessions. Must differ from `JWT_SECRET` (enforced at startup). | | `ADMIN_WEB_ORIGIN` | required to run the admin-api | Public origin of the admin console, for example `https://admin.kanera.example.com`. Also the CORS origin and the base for admin invite links. | | `ADMIN_COOKIE_DOMAIN` | no | Admin refresh-cookie domain override. Set this when the admin portal hostname is outside `COOKIE_DOMAIN`, for example `kanera-admin.example.com` while `COOKIE_DOMAIN=kanera.example.com`. | | `ADMIN_EMAIL` | recommended | Receives the weekly system recap every Monday at 07:00 UTC. With `ADMIN_PASSWORD`, also seeds the first `superadmin` when `admin_user` is empty. | | `ADMIN_PASSWORD` | required for first boot only | Used with `ADMIN_EMAIL` to seed the first `superadmin`; permanent no-op once any admin exists. | | `ADMIN_JWT_ACCESS_TTL` | no | Defaults to `15m`. | | `ADMIN_JWT_REFRESH_TTL_DAYS` | no | Defaults to `7`. | | `ADMIN_LOGIN_RATE_LIMIT_MAX` / `ADMIN_LOGIN_RATE_LIMIT_WINDOW_MS` | no | Per-IP admin login throttle. Default `5` attempts per `300000`ms. | | `ADMIN_TRUST_PROXY` | no | Set `true` when the admin-api sits behind a reverse proxy, so login rate limiting sees the real client IP. | | `ADMIN_AUDIT_LOG_RETENTION_DAYS` | no | Defaults to `1095` (3 years). | | `REDIS_URL` | yes | Valkey Redis-protocol connection URL. The bundled compose file sets this to `redis://valkey:6379/0`; API boot fails if Valkey is unavailable. | | `API_REPLICAS` | no | Number of app API processes Compose keeps running. Defaults to `2`; raise on larger machines. Keep `worker` at `1`. | | `PUBLIC_API_REPLICAS` | no | Number of public integration API processes Compose keeps running. Defaults to `1`; raise if external API traffic needs more capacity. | | `MCP_REPLICAS` | no | Number of MCP server processes Compose keeps running. Defaults to `1`; raise if remote MCP traffic needs more capacity. | | `DATABASE_SSL` | no | Defaults to `false` for the bundled Postgres container. Set `true` only for a database that requires SSL. | | `POSTGRES_BIND_PORT` | no | Host port for the bundled Postgres service. Defaults to `5433`, mapped to container port `5432`. | | `PG_POOL_MAX` | no | Max concurrent DB connections per API/public-api process. Defaults to `20` in Compose. Raise to `30`–`50` on a dedicated host. See Performance tuning. | | `WORKER_PG_POOL_MAX` | no | Max concurrent DB connections for the single worker. Defaults to `5` in compose. | | `PG_CONNECTION_TIMEOUT_MS` | no | Wait for a free pooled connection before failing. Defaults to `5000`. | | `PG_STATEMENT_TIMEOUT_MS` | no | Postgres kills any query exceeding this. Defaults to `30000`. | | `MIGRATION_PG_STATEMENT_TIMEOUT_MS` | no | Statement timeout for the one-shot migrator only. Defaults to `0` (disabled) so schema/data reconciliation is not killed by the API query timeout. | | `NODE_OPTIONS` | no | Passed to Node. Use to pin the heap, for example `--max-old-space-size=8192`. Empty by default. See Performance tuning. | | `UV_THREADPOOL_SIZE` | no | libuv worker-thread pool size for native work. Defaults to `16` in compose. | | `WORKER_PORT` | no | Internal worker healthcheck port. Defaults to `3003`. | | `REALTIME_WEBSOCKET_COMPRESSION_ENABLED` | no | Defaults to `false` to avoid compression CPU and fanout queueing for typical small realtime messages. Set `true` to opt in to fast deflate (level 1, no server context takeover) when bandwidth is constrained and large updates are common. Existing explicit `true` settings remain enabled; set `false` and restart/recreate the app API to adopt the default. HTTP compression is unaffected. | | `REALTIME_WEBSOCKET_COMPRESSION_THRESHOLD_BYTES` | no | Used only when websocket compression is enabled. Minimum outgoing message size to compress; defaults to `1024`. Server context takeover is disabled so this threshold takes effect. | | `JWT_ACCESS_TTL` | no | Defaults to `5m`. | | `JWT_REFRESH_TTL_DAYS` | no | Defaults to `10`. | | `SIGNUPS_ENABLED` | no | Defaults to `true`. Set `false` to close public self-signup/new organisation creation while still allowing existing organisation invite links. | | `EMAIL_VERIFICATION_ENABLED` | no | Defaults to `false`, allowing signup, invite signup, and email changes before SMTP is configured. Set `true` only after outbound mail works. | | `PUBLIC_API_FAILED_KEY_RATE_LIMIT_PER_MINUTE` | no | Per-IP failed `kanera_*` API-key auth throttle. Defaults to `10` in compose. | | `MCP_SERVER_PUBLIC_URL` | yes for the MCP service | Canonical public MCP endpoint URL, for example `https://mcp.kanera.example.com/mcp`. Required in production so OAuth audiences never derive from an untrusted Host header. | | `OPENAI_APPS_CHALLENGE_TOKEN` | no | Public OpenAI plugin domain-verification token. When set, MCP serves it verbatim at `/.well-known/openai-apps-challenge` without authentication. Route this root path to MCP; leave unset when verification is not needed. | | `PUBLIC_API_OAUTH_ISSUER` | required for remote OAuth | Browser-reachable public API origin that serves OAuth metadata, registration, authorization, token, and revocation endpoints. | | `MCP_PUBLIC_URL` | required for remote OAuth | Canonical protected-resource URL; normally the same value as `MCP_SERVER_PUBLIC_URL`. | | `MCP_INTERNAL_SECRET` | yes | Stable random secret shared only by public-api and MCP for audience-bound token exchange. Must differ from `JWT_SECRET`. | | `MCP_UPSTREAM_TIMEOUT_MS` | no | Deadline for MCP calls to the Kanera public API and OAuth delegation. Defaults to `15000`. | | `MCP_TOOL_OUTPUT_MAX_BYTES` | no | Maximum serialized structured result size for one tool call. Defaults to `1048576`; oversized results return a corrective tool error. | | `OAUTH_ISSUER_URL` | no | MCP service override for the OAuth issuer; Compose derives it from `PUBLIC_API_OAUTH_ISSUER`. | | `ALERT_WEBHOOK_URL` | no | A Slack-compatible incoming webhook for deployment readiness and operational alerts (Slack, Zulip `slack_incoming`, Mattermost, Discord, ...). Grafana reuses it for its alerts. | | `OPS_ALERTS_ENABLED` | no | Defaults to `true`; no alerts are sent unless `ALERT_WEBHOOK_URL` is set. | | `OPS_ALERT_THROTTLE_MS` | no | Defaults to `300000`; throttles repeated equivalent alerts. | | `SLOW_REQUEST_LOG_MS` | no | Threshold (ms) for the "slow request" warn log shipped to Loki. Defaults to `2500`. Not an alert trigger — latency alerting is Grafana's p95 rule. | | `METRICS_ENABLED` | no | Exposes Prometheus `/metrics` on each app process. Defaults to `true`. | | `METRICS_TOKEN` | required when metrics are scraped | Bearer token required to scrape `/metrics`; the endpoint returns `404` when it is unset or incorrect. Prometheus refuses to start without it. Min 16 chars. | | `COMPOSE_PROFILES` | no | Set to `monitoring` to start the Prometheus/Grafana/Loki/Alloy/exporters stack. Off by default. | | `COMPOSE_PROJECT_NAME` | required with monitoring | Project name Alloy uses to restrict Docker log discovery to this deployment. Defaults to `kanera`. Platforms like Dokploy auto-generate a suffixed name (e.g. `kanera-app-ggcqgp`) — run `docker compose ls` on the server to find the real value and set this explicitly; wrong value = no logs in Loki. | | `GRAFANA_ADMIN_PASSWORD` | only if monitoring enabled | Initial Grafana admin password. | | `MONITORING_BIND_IP` | no | Host IP that Grafana and Prometheus publish on. Defaults to `127.0.0.1`. Set to the server's `wg0` address to reach dashboards over WireGuard; never `0.0.0.0` in production. | | `GRAFANA_BIND_PORT` | no | Host port for Grafana. Defaults to `3000`. | | `PROMETHEUS_BIND_PORT` | no | Host port for the Prometheus UI. Defaults to `9090`. | | `PROMETHEUS_RETENTION` | no | Prometheus TSDB time retention. Defaults to `30d`. | | `PROMETHEUS_RETENTION_SIZE` | no | Hard disk ceiling for the Prometheus TSDB (oldest blocks drop once exceeded). Defaults to `5GB`. | | `GRAFANA_ROOT_URL` | no | Public Grafana URL; set when serving Grafana under a path/subdomain. | | `POSTGRES_EXPORTER_PASSWORD` | no | Password for the dedicated `kanera_exporter` monitoring role. Defaults to `POSTGRES_PASSWORD`. | | `POSTGRES_EXPORTER_DSN` | no | Overrides the whole `postgres-exporter` connection string (e.g. a managed Postgres). Defaults to the bundled Postgres via the `kanera_exporter` role. | | `SMTP_HOST` | no | Default outbound SMTP host. | | `SMTP_PORT` | no | Defaults to `587`. | | `SMTP_SECURITY` | no | `starttls`, `tls`, or `none`. | | `SMTP_USER` | no | SMTP username. | | `SMTP_PASSWORD` | no | SMTP password. | | `SMTP_FROM_EMAIL` | no | Default sender address. | | `SMTP_FROM_NAME` | no | Defaults to `Kanera`. | | `SMTP_IDENTITY_DOMAIN` | no | Domain used for SMTP EHLO and Message-ID headers. Defaults to `SMTP_FROM_EMAIL`'s domain; set this to your real sending domain, not `.local`. | | `INTERNAL_NOTIFICATION_EMAILS` | no | Comma-separated internal recipients for plain-text signup, invite-acceptance, and positive paid-subscription invoice alerts. Requires env SMTP. | | `LIFECYCLE_FEEDBACK_EMAIL` | no | Hosted mode only. Address that the inactive-account and active-customer lifecycle emails invite customers to write to. Defaults to `support@kanera.app`. | | `SUPPORT_SESSION_TTL_MINUTES` | no | Lifetime in minutes of a support-session token minted by the management portal (`POST /admin/orgs/:clientId/support-session`). Defaults to `60`. No refresh companion is issued. | | `S3_REGION` | no | Enables deployment-wide S3 storage when set with bucket and credentials. | | `S3_BUCKET` | no | S3 bucket for uploads. | | `S3_ACCESS_KEY_ID` | no | S3 access key id. | | `S3_SECRET_ACCESS_KEY` | no | S3 secret access key. | | `S3_ENDPOINT` | no | S3-compatible endpoint for non-AWS providers. | | `S3_PUBLIC_URL_PREFIX` | no | Optional CDN/public object URL prefix. | | `DB_BACKUPS_ENABLED` | no | Set `true` to enable non-development full Postgres backups to S3. | | `DB_BACKUP_ENCRYPTION_PASSPHRASE` | only if backups enabled | GPG symmetric encryption passphrase for database backups. Store it securely; it is required for restore. | | `DB_BACKUP_TIMES_UTC` | no | Comma-separated UTC run times. Defaults to `00:15,12:15,16:45`. | | `DB_BACKUP_RETENTION_DAYS` | no | Days of database backups to keep. Defaults to `14`. | | `DB_BACKUP_S3_PREFIX` | no | S3 object prefix for database backups. Defaults to `backups/postgres`. | | `DB_BACKUP_S3_BUCKET` | no | Optional backup bucket override. Defaults to `S3_BUCKET`. | | `DB_BACKUP_S3_REGION` | no | Optional backup region override. Defaults to `S3_REGION`. | | `DB_BACKUP_S3_ENDPOINT` | no | Optional backup S3-compatible endpoint override. Defaults to `S3_ENDPOINT`. | | `DB_BACKUP_S3_ACCESS_KEY_ID` | no | Optional backup access key override. Defaults to `S3_ACCESS_KEY_ID`. | | `DB_BACKUP_S3_SECRET_ACCESS_KEY` | no | Optional backup secret key override. Defaults to `S3_SECRET_ACCESS_KEY`. | | `GITHUB_APP_ID` | no | GitHub App id for private repository link previews. Register one App per deployment with Setup URL `${WEB_ORIGIN}/settings/org` and read access to contents, metadata, and pull requests. Leave unset to bootstrap an App in-app instead. | | `GITHUB_APP_SLUG` | no | GitHub App slug (the `app/` segment of its install URL). Required alongside `GITHUB_APP_ID`. | | `GITHUB_APP_PRIVATE_KEY` | no | GitHub App private key PEM, with newlines escaped as `\n`. Required alongside `GITHUB_APP_ID`. | | `GITHUB_APP_CLIENT_ID` | hosted | The App's OAuth client id. Enable "Request user authorization (OAuth) during installation" and set the Callback URL to `${WEB_ORIGIN}/settings/org`. Lets Kanera verify that the user connecting an installation can see it on GitHub; hosted deployments refuse to connect installations without it. | | `GITHUB_APP_CLIENT_SECRET` | hosted | An OAuth client secret generated for the App. Required alongside `GITHUB_APP_CLIENT_ID`. | | `GITHUB_APP_WEBHOOK_SECRET` | no | The App's webhook secret. Set the App's Webhook URL to `${API_PUBLIC_URL}/api/integrations/github/webhook` and subscribe it to "Pull request" events; pull requests whose branch, title or body name a card key are then linked to that card. Without it, webhooks are refused. | | `TRELLO_API_KEY` | no | Trello app API key for authenticated Trello attachment imports. See `trello_import.md`. | ## Notes - `docker/postgres/init.sql` runs only when the Postgres volume is first created. - PostgreSQL 18 provides `uuidv7()` natively. New rows use time-ordered UUID defaults. - `COOKIE_SECURE=true` is required behind HTTPS, or refresh cookies will not work. - If you use a managed database, set `DATABASE_URL` for `api` and `public-api`, set `DATABASE_SSL=true` if your provider requires SSL, and remove or ignore the bundled `postgres` service. - Valkey is a hard dependency. If you replace the bundled Valkey service with a managed Valkey or Redis-compatible instance, set `REDIS_URL` for `api`, `public-api`, and `worker` and keep the dependency healthy before deploying app processes. - The `mcp` service talks to `public-api` over `KANERA_PUBLIC_API_URL`, which is set to `http://public-api:3001` in the bundled compose file. - If the required S3 variables are set, S3 takes precedence over local uploads and organisation-level storage settings.