Run Kythene on your own infrastructure from the published container image - including fully air-gapped, with no external identity provider and no network egress. This guide covers install, configuration, the first-run wizard, SSO, licensing, upgrades and backup.
Kythene self-hosts free for a single user by default (one signed-in person). For a team, a licence lifts the seat cap: Self-host Team covers your whole team, and Enterprise self-host adds SSO, audit and support for larger or regulated deployments. See Licensing.
The hosted app lives at
https://kythene.com. Everywhere the other docs say that URL, self-hosters swap in their own.
What you need
- Docker + Docker Compose on a single host, or an equivalent container runtime.
- PostgreSQL 16 or newer, with pgvector 0.5.0 or newer (the
vectorextension). The app applies all migrations on boot, includingCREATE EXTENSION vector, so it connects as a role that may create the extension. 0.5.0 is the pgvector floor because the migrations build HNSW indexes, which older pgvector does not support; 16 is the supported PostgreSQL floor - the schema needs nothing newer, and no other extensions. The bundled stack pinspgvector/pgvector:pg18only because it is a convenient ready-made image, not because 18 is required. Managed Postgres works the same: RDS and Aurora both offerpgvectoron Postgres 16, with no change to the app. - Any S3-compatible object store for artifact and version bytes - AWS S3, MinIO, or anything else that speaks the S3 API. MinIO is only the example bundled below; point the app at whichever store you run. Blob storage is disabled when the endpoint is blank, and publishing then refuses - so keep it configured.
- A reverse proxy terminating TLS (Caddy, nginx, Traefik, Cloudflare, ...) in front of the app. The app serves plain HTTP (
tls: none); never expose its port directly.
Install (image + Compose)
The image is published at ghcr.io/kythene/app:latest (and per-version tags). Below is a complete single-node stack - the app plus its own Postgres and MinIO, and a one-shot bucket-creation container. Save it as docker-compose.yml.
This is one convenient shape, not the only one: it bundles Postgres and an S3 store so a single docker compose up gets you running. The same image runs unchanged against managed infrastructure - RDS or Aurora for Postgres, a real S3 bucket for storage - by pointing KYTHENE_DB and the KYTHENE_MINIO_* settings at them instead of the bundled services. Nothing in the app changes; only the connection settings do.
services:
app:
image: ${KYTHENE_IMAGE:-ghcr.io/kythene/app:latest}
restart: unless-stopped
env_file: .env
depends_on:
postgres: { condition: service_healthy }
createbucket: { condition: service_completed_successfully }
environment:
KYTHENE_PORT: ${KYTHENE_PORT:-8080}
# Wire the app to the bundled services (these override .env).
KYTHENE_DB: postgres://${POSTGRES_USER:-kythene}:${POSTGRES_PASSWORD:-kythene}@postgres:5432/${POSTGRES_DB:-kythene}?sslmode=disable&search_path=public
KYTHENE_MINIO_ENDPOINT: minio:9000
KYTHENE_MINIO_ACCESS_KEY: ${MINIO_ROOT_USER:-kythene}
KYTHENE_MINIO_SECRET_KEY: ${MINIO_ROOT_PASSWORD:-changeme-minio}
KYTHENE_MINIO_BUCKET: ${KYTHENE_MINIO_BUCKET:-kythene}
KYTHENE_MINIO_USE_SSL: "false"
volumes:
- appdata:/data # writable volume for the auto-refreshed licence key
ports:
- "${KYTHENE_PORT:-8080}:${KYTHENE_PORT:-8080}" # front this with a TLS proxy
postgres:
image: pgvector/pgvector:pg18 # ships the `vector` extension; any Postgres 16+ with pgvector works
restart: unless-stopped
environment:
POSTGRES_USER: ${POSTGRES_USER:-kythene}
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-kythene}
POSTGRES_DB: ${POSTGRES_DB:-kythene}
volumes:
- pgdata:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER:-kythene} -d ${POSTGRES_DB:-kythene}"]
interval: 10s
timeout: 5s
retries: 10
start_period: 20s
minio: # the example S3 store; swap for AWS S3 or another provider by changing the KYTHENE_MINIO_* settings
image: minio/minio:RELEASE.2025-09-07T16-13-09Z
restart: unless-stopped
command: server /data --console-address ":9001"
environment:
MINIO_ROOT_USER: ${MINIO_ROOT_USER:-kythene}
MINIO_ROOT_PASSWORD: ${MINIO_ROOT_PASSWORD:-changeme-minio}
volumes:
- miniodata:/data
ports:
- "9001:9001" # optional MinIO console; remove if unwanted
createbucket:
image: minio/mc:RELEASE.2025-08-13T08-35-41Z
depends_on:
minio: { condition: service_started }
entrypoint: >
/bin/sh -c "
until mc alias set local http://minio:9000 ${MINIO_ROOT_USER:-kythene} ${MINIO_ROOT_PASSWORD:-changeme-minio}; do
echo 'waiting for minio...'; sleep 2;
done &&
mc mb --ignore-existing local/${KYTHENE_MINIO_BUCKET:-kythene} &&
echo 'bucket ready';
"
restart: "no"
volumes:
appdata:
pgdata:
miniodata:Alongside it, save this as .env and fill it in (every setting is documented in Configuration below):
# Required
KYTHENE_URL=https://kythene.example.com # the exact URL you reach the app on, no trailing slash
KYTHENE_PORT=8080
KYTHENE_SETUP_TOKEN=change-me-first-run # gates first-run /setup; without it the wizard is locked (fail-closed)
# Bundled Postgres + MinIO - CHANGE THESE PASSWORDS before first run
POSTGRES_USER=kythene
POSTGRES_PASSWORD=changeme-postgres
POSTGRES_DB=kythene
MINIO_ROOT_USER=kythene
MINIO_ROOT_PASSWORD=changeme-minio # min 8 chars
KYTHENE_MINIO_BUCKET=kytheneThat is the whole configuration for a free single-user install - it runs under the seat cap with no licence. You only add the licence settings once you have a paid key; see Applying a licence below:
# Adding a licence (optional - omit entirely for the free tier)
KYTHENE_LICENCE_KEY_FILE=/data/licence.key # writable path (the compose mounts /data): a key pasted in-app or auto-refreshed is written here so it survives restarts
KYTHENE_LICENCE_KEY=... # OR paste the token here / in-app instead of using the file
KYTHENE_LICENCE_ENFORCE=true # refuse to start unless a licence key is present
KYTHENE_LICENCE_REFRESH_URL=https://www.kythene.com # OPTIONAL: opt into auto-renew; omit (the default) to run fully offlineThen bring it up:
docker compose up -dOn boot the app connects to Postgres, applies migrations forward (idempotent), and starts serving. The createbucket service creates the object-store bucket first - the app does not create it itself. Point your TLS proxy at the app's port (8080 by default) and open your KYTHENE_URL.
The image is FROM scratch - a single static binary that listens on KYTHENE_PORT, declares /data as a volume (for the writable licence key), and has a self-probing healthcheck subcommand as its container health check.
Configuration and secrets
Every setting is read from the environment with the prefix KYTHENE_ (viper AutomaticEnv). A value that starts with / is treated as a file path whose contents are the real secret - useful for Docker/Kubernetes secrets.
Required
| Key | Example | Purpose |
|---|---|---|
KYTHENE_URL | https://kythene.example.com | Public base URL; builds links, cookies and OAuth callbacks. No trailing slash. |
KYTHENE_PORT | 8080 | HTTP listen port inside the container |
KYTHENE_DB | postgres://user:pass@host:5432/kythene?sslmode=disable&search_path=public | Postgres DSN |
KYTHENE_MINIO_ENDPOINT | minio:9000 | Object store host:port (blank disables blob storage) |
KYTHENE_MINIO_ACCESS_KEY / KYTHENE_MINIO_SECRET_KEY | ... | Object-store credentials |
KYTHENE_MINIO_BUCKET | kythene | Bucket name (create it; the app does not) |
KYTHENE_MINIO_USE_SSL | false | true for a TLS object-store endpoint |
KYTHENE_MINIO_REGION | (blank) | S3 region, if your provider needs one |
TLS is always terminated by your proxy (tls: none); the app has no TLS setting to enable.
Sign-in
| Key | Default | Purpose |
|---|---|---|
KYTHENE_SETUP_TOKEN | (unset) | Bootstrap token that gates the first-run /setup wizard. Fail-closed: with none set, the wizard refuses to create an admin, so a fresh instance can't be claimed by whoever reaches it first. Set it, restart, then open /setup?token=<this>. Printed to the container logs while first-run is pending; ignored once setup is complete. |
KYTHENE_LOCAL_AUTH_ENABLE | true | Email + password sign-in. Bootstraps the first admin with no external IdP; the only sign-in an air-gapped install needs. |
KYTHENE_GITHUB_CLIENTID / KYTHENE_GITHUB_CLIENTSECRET | (unset) | GitHub sign-in (button appears only when both are set) |
KYTHENE_GOOGLE_CLIENTID / KYTHENE_GOOGLE_CLIENTSECRET | (unset) | Google sign-in |
KYTHENE_MICROSOFT_CLIENTID / KYTHENE_MICROSOFT_CLIENTSECRET | (unset) | Microsoft sign-in |
KYTHENE_APPLE_CLIENTID / KYTHENE_APPLE_CLIENTSECRET | (unset) | Apple sign-in |
Licence and seats
All optional - the free single-user tier needs none of these.
| Key | Default | Purpose |
|---|---|---|
KYTHENE_LICENCE_ENFORCE | false | true = refuse to start unless a licence key is present (paid installs, so a misconfigured deploy fails loudly rather than booting unlicensed). false = run under the free seat cap with no key. Either way, a licence that later lapses degrades to read-only after the grace window rather than stopping the app - see KYTHENE_LICENCE_GRACE. |
KYTHENE_LICENCE_KEY | (unset) | The licence token, or a /path to a file holding it. Usually entered in-app instead. |
KYTHENE_LICENCE_KEY_FILE | (unset) | Writable path a key pasted in-app or auto-refreshed is read from and rewritten to, so it survives restarts. Unset by default - set it for persistence; the reference .env above sets it to /data/licence.key, where the compose mounts a writable volume. Without it, an in-app licence lives only in memory and is lost on restart. |
KYTHENE_LICENCE_REFRESH_URL | (blank) | Off by default - nothing phones home. Set it to the storefront base URL (https://www.kythene.com) to opt into auto-renew: as expiry approaches the instance fetches a refreshed key. Blank = offline re-validation only, no outbound calls. |
KYTHENE_LICENCE_REFRESH_INTERVAL | 12h | How often to check for a refreshed key - only when KYTHENE_LICENCE_REFRESH_URL is set. |
KYTHENE_LICENCE_GRACE | 72h | How long past expiry to keep serving fully before dropping to read-only. Past the window, existing content stays readable and exportable and new writes pause until you renew - the app never stops or shuts anyone out. |
KYTHENE_FREE_SELFHOST_SEATS | 1 | Unlicensed seat cap (signed-in humans, instance-wide). Default 1 = free single user; a licence lifts it for a team (Self-host Team or Enterprise). 0 = unlimited. |
Optional
| Key | Purpose |
|---|---|
KYTHENE_CONSENT_SIGNING_KEY | Cookie/consent signing key (>= 16 bytes). If unset, derived from db + url - fine for a single instance. |
KYTHENE_ANTHROPIC_APIKEY | Anthropic API key - enables the built-in LLM/assistant features. Blank = off. |
KYTHENE_MEMORY_EMBED_URL | Embedding server URL(s) for semantic recall (comma-separated, tried in order). Blank = semantic recall off; recall then uses the full-text index only. |
KYTHENE_MEMORY_EMBED_MODEL | Embedding model (default bge-m3, 1024-dim). |
KYTHENE_MEMORY_EMBED_DIMS | Embedding dimensionality (default 1024); must match both the model and the vector(1024) embeddings column. |
KYTHENE_MEMORY_EMBED_KIND | Embedding backend protocol: ollama (default) or openai (any OpenAI-compatible /v1/embeddings server). |
KYTHENE_MEMORY_EMBED_APIKEY | Bearer key for an openai-kind server that requires one; blank for keyless local servers. |
KYTHENE_MEMORY_PROVENANCE | Derived-memory provenance. When true, a memory written after the caller read restricted content (a private item, or a private project) inherits the tightest source restriction rather than being stored open. Default false. |
KYTHENE_MEMORY_PROVENANCE_WINDOW | How far back the read-log is consulted for restricted reads by the same session when provenance is on. Default 1h. |
KYTHENE_POSTHOG_APIKEY | Product analytics. Blank = off. |
KYTHENE_OTLP_TRACES_ENDPOINT / KYTHENE_OTLP_METRICS_ENDPOINT / KYTHENE_OTLP_LOGS_ENDPOINT | OpenTelemetry export. Blank = off. |
KYTHENE_ENTITLEMENTS_ENFORCE | Hosted-only master switch. Leave false on self-host. |
Email (for notification rules)
Outbound email is off until you configure it, so a fresh install sends nothing. Notification rules and any email a rule would send stay inert until these are set. Delivery is via Amazon SES; there is no SMTP path.
| Key | Purpose |
|---|---|
KYTHENE_MAIL_SES_REGION | AWS SES region (e.g. eu-west-2). This is the on/off switch - blank means no mail is sent. |
KYTHENE_MAIL_FROM | Default From address; must be an identity you have verified in SES. |
KYTHENE_MAIL_FROM_NAME | From display name (defaults to Kythene). |
KYTHENE_MAIL_SNS_TOPIC_ARNS | Optional comma-separated SNS topic ARN allowlist for the bounce/complaint webhook. |
SES credentials come from the standard AWS chain (environment variables or an instance role), not a Kythene key. An air-gapped install simply leaves KYTHENE_MAIL_SES_REGION blank and uses in-app inbox notifications instead.
Semantic recall needs an embedder. Out of the box a self-host install uses full-text recall only - the bundled Postgres carries the vector extension, but nothing writes embeddings until you point KYTHENE_MEMORY_EMBED_URL at an embedding server (an Ollama or any OpenAI-compatible /v1/embeddings endpoint) serving bge-m3. Leave it blank and recall works exactly as before, just without the meaning-based match. It is not required, and no data leaves the box when it is unset.
Derived-memory provenance (KYTHENE_MEMORY_PROVENANCE=true) closes a subtle gap: an instance that reads restricted content and then writes a memory from it would, by default, store an open memory - a summary that ordinary recall could later hand to someone who could not read the source. With it on, the memory inherits the tightest restriction of what the session read - a single shared private project where that is provably safe, otherwise private to the author. It errs towards over-restricting (a memory becomes private to its author) rather than leaking, and an instance can always scope a sensitive memory explicitly. It is off by default because it is a deliberate, auditable policy; regulated and compliance-sensitive installs will usually want it on.
First run - the setup wizard
On a fresh instance the first person in becomes the administrator.
- With local auth on (default): the first-run wizard at
/setupis gated byKYTHENE_SETUP_TOKEN(fail-closed - without it set the wizard is locked and creates no admin, so nobody can claim the instance before you do). Set the token, restart, then open/setup?token=<your token>- it is also printed to the container logs while first-run is pending. Create the first admin with an email and password and, if you have one, paste a licence key there and then. That account owns the instance. - With an OAuth provider configured: the first person to sign in becomes the admin instead.
For an air-gapped install, leave local auth on and use the wizard - no external provider or network access is required.
Add more people afterwards from the admin area:
- Password users: Admin -> Local users (
/admin/local-users). - SSO users: anyone who can sign in through a configured provider joins automatically, subject to the seat cap.
Configuring SSO
To add single sign-on, register an OAuth app with the provider and set both its id and secret. A provider's button appears only when both are present. The callback URL is:
<KYTHENE_URL>/auth/{ref}/callbackwhere {ref} is github, google, microsoft or apple. For example, with KYTHENE_URL=https://kythene.example.com:
- GitHub:
https://kythene.example.com/auth/github/callback - Google:
https://kythene.example.com/auth/google/callback
Register that exact URL in the provider's console, then set the matching KYTHENE_*_CLIENTID / KYTHENE_*_CLIENTSECRET and restart.
Generic OIDC
For any OpenID Connect provider (Okta, Entra ID, Keycloak, Auth0 and the like), configure the generic OIDC directory instead of a named provider:
| Setting | Example | Notes |
|---|---|---|
KYTHENE_OIDC_ISSUER | https://id.example.com | The provider's issuer URL; Kythene discovers the endpoints from it |
KYTHENE_OIDC_CLIENTID / KYTHENE_OIDC_CLIENTSECRET | (from the provider) | The OIDC client credentials |
KYTHENE_OIDC_ALLOWED_DOMAINS | example.com,example.org | Optional: restrict sign-in to these email domains (comma-separated) |
KYTHENE_OIDC_NAME | Okta | Optional button label (defaults to "SSO") |
The SSO button appears once the issuer, client id and client secret are all set. The callback URL is <KYTHENE_URL>/auth/oidc/callback - register that exact URL in your provider, then restart.
SAML and SCIM provisioning are not available yet. Use generic OIDC (above), a named OAuth provider, or local email + password auth.
Applying and renewing a licence
An unlicensed instance runs free for a single user (KYTHENE_FREE_SELFHOST_SEATS defaults to 1). Once the cap is reached, further sign-ins are refused until a licence is applied. A licence lifts the cap to your plan's seat count - Self-host Team for a normal team, Enterprise for larger, regulated or air-gapped deployments - and unlocks that plan's entitlements.
Apply or replace a licence at any time:
- In-app: Admin -> Licence (
/admin/licence) - paste the key. WhenKYTHENE_LICENCE_KEY_FILEis set (the reference.envsets it to/data/licence.key, a writable volume), the key is written there so it survives restarts; leave that unset and an in-app key lives only in memory and is lost on restart. If you setKYTHENE_LICENCE_REFRESH_URL, it also auto-refreshes so an active subscription never lapses in place; leave it unset and refresh is off. - By config: set
KYTHENE_LICENCE_KEYbefore first run.
Set KYTHENE_LICENCE_ENFORCE=true to make the app refuse to start unless a licence key is present - a guard for paid deployments against booting unlicensed by misconfiguration. A licence that lapses in place does not stop the app: it keeps serving fully through the grace window, then drops to read-only until renewed. Validation is offline, and the renewal fetch is off unless you set KYTHENE_LICENCE_REFRESH_URL, so an air-gapped install leaves it blank and re-validates the key it already holds - nothing phones home.
Upgrades
docker compose pull app
docker compose up -dNew migrations apply forward automatically on boot. Migrations are forward-only and additive; take a Postgres backup before a major upgrade as normal practice.
Backup and ops
The instance's state lives in three places - back up all three:
- Postgres - all metadata: workspaces, projects, published work, memory, members, audit. Use
pg_dumpor snapshot the volume. - The object store - the artifact and version bytes. Because they are content-addressed, an object-store backup stays consistent with any point-in-time DB backup.
- The licence key (
/data/licence.key) and your.env(secrets and the key-derivation inputs).
The container's health check is the binary's own healthcheck subcommand, so docker compose ps reports health directly. Point KYTHENE_OTLP_* at a collector if you want traces, metrics and logs.