Security and hardening
This page collects the security decisions for a self-hosted Kanera deployment. It covers what faces the internet, which secrets matter, what the server contacts, and which settings to tighten once the stack is running.
Read it after Getting Started. The detailed settings live on the other self-host pages, which are linked from each section.
What faces the internet
The bundled docker-compose.yml publishes none of the application services. You decide what to publish, usually through a compose.override.yml and a reverse proxy. See Services and Routing.
| Service | Exposure | Notes |
|---|---|---|
web | Public, behind your HTTPS proxy | The only service browsers need. Its nginx forwards app API and realtime traffic to api internally. |
public-api | Optional, public | Publish it only for integrations, the API reference, or AI agent OAuth. |
mcp | Optional, public | Publish it only if remote AI clients should connect. |
api | Internal only | Never give the main API its own public domain. |
worker | Internal only | Runs schedulers and outbound delivery. |
postgres | Host loopback only | Bound to 127.0.0.1:5433 by default (POSTGRES_BIND_PORT). Use an SSH tunnel for remote access. |
valkey | Internal only | No published port. It runs without a password on the Compose network, so never publish it. |
admin-api | Off by default | Starts only with COMPOSE_PROFILES=admin. Put it on its own domain if you enable it. |
| Monitoring | Off by default | With the monitoring profile, Prometheus and Grafana bind to 127.0.0.1 unless you set MONITORING_BIND_IP, for example to a VPN address. Never bind them to 0.0.0.0. |
The fewer services you publish, the less there is to attack. A deployment that uses only the web app needs a single public domain.
TLS and the reverse proxy
Kanera's containers speak plain HTTP. Terminate TLS at your reverse proxy or load balancer (Caddy, Traefik, nginx, Dokploy, or a cloud load balancer), and serve every public domain over HTTPS.
- Set
COOKIE_SECURE=true, and makeWEB_ORIGINandCOOKIE_DOMAINmatch the public HTTPS address. - The web container sends
Strict-Transport-SecuritywithincludeSubDomainsand a one-year lifetime. Browsers will then insist on HTTPS for subdomains of the app domain too, so keep API, MCP, and admin subdomains on HTTPS. - It also sends a Content Security Policy,
X-Frame-Options: SAMEORIGIN,X-Content-Type-Options: nosniff, and a restrictivePermissions-Policy. Don't strip them at the proxy.
Rate limits and sign-in throttles use the client IP. That IP is only trustworthy when a proxy overwrites the forwarding headers:
| Setting | Compose default | Set to true when |
|---|---|---|
API_TRUST_PROXY | false | The app sits behind a proxy that sets the real client IP. |
PUBLIC_API_TRUST_PROXY | false | The same, for the public API. |
MCP_TRUST_PROXY | true | Leave it true only while MCP is reachable solely through such a proxy. |
ADMIN_TRUST_PROXY | true | Leave it true only while the admin API is reachable solely through such a proxy. |
If a service trusts proxy headers but clients can reach it directly, they can forge their IP and get around per-IP limits.
Secrets
Generate each secret separately, for example with openssl rand -hex 32, and keep them in a secret store outside the server as well as in .env. In production, the API refuses to start while known development placeholders are still in use. It also refuses to start when SECRETS_ENCRYPTION_KEY is missing or equal to JWT_SECRET, or when MCP_INTERNAL_SECRET or ADMIN_JWT_SECRET equals JWT_SECRET.
| Secret | Protects | If you change it |
|---|---|---|
POSTGRES_PASSWORD | The database login | Update the running database user too. |
JWT_SECRET | App sessions | Everyone is signed out. This is the quickest way to end every session after a suspected leak. |
MFA_ENCRYPTION_KEY | Authenticator secrets and recovery codes | Every enrolled authenticator stops working. |
MEDIA_SIGNING_SECRET | Signed media URLs | Existing media links stop resolving. |
SECRETS_ENCRYPTION_KEY | Stored SMTP, storage, webhook, chat destination, and GitHub App credentials | Stored credentials can no longer be decrypted and must be entered again. |
MCP_INTERNAL_SECRET | The private exchange between mcp and public-api | Set the same new value on both services. In-flight MCP requests may fail. |
ADMIN_JWT_SECRET | Management portal sessions | Portal admins are signed out. |
METRICS_TOKEN | /metrics scraping | Update Prometheus to match. |
DB_BACKUP_ENCRYPTION_PASSPHRASE | Encrypted backups | Backups made with the old passphrase still need the old passphrase. |
Kanera has no built-in way to re-encrypt stored data under a new key. Treat MFA_ENCRYPTION_KEY and SECRETS_ENCRYPTION_KEY as long-lived, and plan a rotation before you need one. See Secrets worth keeping.
Metrics are on by default, but /metrics returns 404 until METRICS_TOKEN is set. The monitoring profile refuses to start without METRICS_TOKEN and GRAFANA_ADMIN_PASSWORD.
Outbound traffic
Kanera has no update check. Product analytics is off by default and intended only for Kanera Cloud. The server contacts only destinations that you, your admins, or your users configure:
- Your SMTP server, S3-compatible storage, and backup bucket.
- Browser push services, once an organisation admin enables push.
- Workspace webhooks and chat destinations (Slack, Discord, Telegram, Zulip) set up by workspace admins.
- Personal ntfy, Gotify, or webhook channels set up by users.
ALERT_WEBHOOK_URLfor operational alerts, if set.- Trello, during an import that copies attachments with
TRELLO_API_KEY, and GitHub, when a GitHub App is configured for link previews. - Cloudflare Turnstile, only when its keys are set. Leave them unset for normal self-hosting.
One destination is not operator-configured. When an AI client calls the MCP search.docs tool, the MCP service downloads the public documentation index from https://www.kanera.app/docs-search.json and searches it locally. The search query is not sent.
Private networks
Workspace webhooks and chat destinations must use public HTTPS addresses. Kanera refuses loopback, private, CGNAT, link-local and cloud metadata, and other reserved addresses. It checks the address again at delivery time and does not follow redirects. That stops a webhook or chat destination from being pointed at services inside your network.
Personal notification channels follow the same rule unless you opt in:
KANERA_ALLOW_PRIVATE_NOTIFICATION_DESTINATIONS=true
This lets users send ntfy, Gotify, and personal webhook notifications to http:// and private-network receivers. Link-local and metadata addresses, multicast, and other reserved ranges stay blocked. It does not relax the rule for workspace webhooks or chat destinations. Turn it on only if you accept that any user can make the server send requests to hosts on your internal network. See Email and Notifications.
Backups
The optional db-backup service encrypts each PostgreSQL dump with GPG symmetric AES-256 before uploading it to S3-compatible storage. A manual pg_dump is not encrypted, so encrypt it yourself before it leaves the server. Uploaded files in the kanera_uploads volume are not covered by db-backup and need their own backup. Keep backups and the backup passphrase off the server's primary disk. See Storage and Backups.
Updates
Security fixes are provided for the current main branch and the latest tagged release. Stay on one of those, read the release notes, and back up before updating. Third-party images in the compose file are pinned to exact versions, so a routine redeploy doesn't change PostgreSQL or Valkey without a reviewed change. See Updates and Scaling.
Accounts and sign-in
- Close public signup. After creating your organisation, set
SIGNUPS_ENABLED=false. Invitation links keep working. - Verify email addresses. Once outbound mail works, set
EMAIL_VERIFICATION_ENABLED=true. - Keep auth throttling on.
AUTH_RATE_LIMIT_ENABLEDdefaults totrue, allowing 10 attempts per minute per IP. - Require MFA. Organisation admins can require an authenticator for everyone under Profile settings -> Organisation. See Profile and Security.
- Session lifetimes. App access tokens last 5 minutes and refresh sessions 10 days (
JWT_ACCESS_TTL,JWT_REFRESH_TTL_DAYS). - Management portal. It is off by default and uses its own accounts and secret. If you enable it, its support sessions act as a target organisation's owner. Each one is recorded in an audit log and expires on its own, after 60 minutes by default (
SUPPORT_SESSION_TTL_MINUTES, at most 480).
AI agent access
Expose mcp and the OAuth routes on public-api only if you want remote AI clients. Plan gating doesn't apply to self-hosted deployments, so once MCP is reachable, any member can connect an agent that acts with that member's permissions.
- Set
MCP_SERVER_PUBLIC_URLandPUBLIC_API_OAUTH_ISSUERto your own domains. OAuth tokens are bound to that MCP address and rejected by/api/v1. - Any client can register itself at
/oauth/register. Registering grants nothing until a signed-in user approves the connection on the consent screen. - Authenticated public API and MCP requests are limited to 400 per minute per credential by default. Failed key attempts are limited to 10 per minute per IP. See Environment Reference.
- The MCP service logs each tool call's name, duration, and outcome, but not its arguments.
See Integrations and APIs for setup and Agent permissions and security for what an agent can do.
Report a vulnerability
Do not open a public issue for a security problem. Email [email protected] with a description, reproduction steps, the affected component, and whether it affects the default self-hosted configuration. Reports are acknowledged within five business days. See SECURITY.md in the Kanera repository for scope and disclosure.