DEPLOY.md
Rama main · réplica de lectura del repositorio original.
# 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=<openssl rand -hex 32>
JWT_SECRET=<openssl rand -hex 32>
MFA_ENCRYPTION_KEY=<openssl rand -hex 32>
MEDIA_SIGNING_SECRET=<openssl rand -hex 32>
SECRETS_ENCRYPTION_KEY=<openssl rand -hex 32>
```
`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=<openssl rand -hex 32> # 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=<choose a strong 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_<env>_...` 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=<openssl rand -hex 32>
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=<openssl rand -hex 32>
```
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=<choose a strong password>
METRICS_TOKEN=<openssl rand -hex 32>
ALERT_WEBHOOK_URL=<Slack-compatible incoming webhook>
# 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/<slug>` 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.