Cookies on Kythene

We use cookies and similar technologies for the things below. You can accept all, reject everything except what's essential, or pick what you're OK with.

Preferences
Remembers things like your last workspace and how you had a list sorted. Improves the experience but the site works without.
Improvement
Anonymous usage measurement so we can fix bugs and prioritise work.
Marketing
Lets us measure whether ads we run send people who actually use the site. We don't share personal data with advertisers.

Read our cookies policy and the privacy policy. California residents: Do Not Sell or Share My Personal Information.

Loading…

Self-hosting

Self-hosting Kythene

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 vector extension). The app applies all migrations on boot, including CREATE 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 pins pgvector/pgvector:pg18 only because it is a convenient ready-made image, not because 18 is required. Managed Postgres works the same: RDS and Aurora both offer pgvector on 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=kythene

That 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 offline

Then bring it up:

docker compose up -d

On 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

KeyExamplePurpose
KYTHENE_URLhttps://kythene.example.comPublic base URL; builds links, cookies and OAuth callbacks. No trailing slash.
KYTHENE_PORT8080HTTP listen port inside the container
KYTHENE_DBpostgres://user:pass@host:5432/kythene?sslmode=disable&search_path=publicPostgres DSN
KYTHENE_MINIO_ENDPOINTminio:9000Object store host:port (blank disables blob storage)
KYTHENE_MINIO_ACCESS_KEY / KYTHENE_MINIO_SECRET_KEY...Object-store credentials
KYTHENE_MINIO_BUCKETkytheneBucket name (create it; the app does not)
KYTHENE_MINIO_USE_SSLfalsetrue 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

KeyDefaultPurpose
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_ENABLEtrueEmail + 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.

KeyDefaultPurpose
KYTHENE_LICENCE_ENFORCEfalsetrue = 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_INTERVAL12hHow often to check for a refreshed key - only when KYTHENE_LICENCE_REFRESH_URL is set.
KYTHENE_LICENCE_GRACE72hHow 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_SEATS1Unlicensed 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

KeyPurpose
KYTHENE_CONSENT_SIGNING_KEYCookie/consent signing key (>= 16 bytes). If unset, derived from db + url - fine for a single instance.
KYTHENE_ANTHROPIC_APIKEYAnthropic API key - enables the built-in LLM/assistant features. Blank = off.
KYTHENE_MEMORY_EMBED_URLEmbedding 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_MODELEmbedding model (default bge-m3, 1024-dim).
KYTHENE_MEMORY_EMBED_DIMSEmbedding dimensionality (default 1024); must match both the model and the vector(1024) embeddings column.
KYTHENE_MEMORY_EMBED_KINDEmbedding backend protocol: ollama (default) or openai (any OpenAI-compatible /v1/embeddings server).
KYTHENE_MEMORY_EMBED_APIKEYBearer key for an openai-kind server that requires one; blank for keyless local servers.
KYTHENE_MEMORY_PROVENANCEDerived-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_WINDOWHow far back the read-log is consulted for restricted reads by the same session when provenance is on. Default 1h.
KYTHENE_POSTHOG_APIKEYProduct analytics. Blank = off.
KYTHENE_OTLP_TRACES_ENDPOINT / KYTHENE_OTLP_METRICS_ENDPOINT / KYTHENE_OTLP_LOGS_ENDPOINTOpenTelemetry export. Blank = off.
KYTHENE_ENTITLEMENTS_ENFORCEHosted-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.

KeyPurpose
KYTHENE_MAIL_SES_REGIONAWS SES region (e.g. eu-west-2). This is the on/off switch - blank means no mail is sent.
KYTHENE_MAIL_FROMDefault From address; must be an identity you have verified in SES.
KYTHENE_MAIL_FROM_NAMEFrom display name (defaults to Kythene).
KYTHENE_MAIL_SNS_TOPIC_ARNSOptional 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 /setup is gated by KYTHENE_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}/callback

where {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:

SettingExampleNotes
KYTHENE_OIDC_ISSUERhttps://id.example.comThe 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_DOMAINSexample.com,example.orgOptional: restrict sign-in to these email domains (comma-separated)
KYTHENE_OIDC_NAMEOktaOptional 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. When KYTHENE_LICENCE_KEY_FILE is set (the reference .env sets 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 set KYTHENE_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_KEY before 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 -d

New 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:

  1. Postgres - all metadata: workspaces, projects, published work, memory, members, audit. Use pg_dump or snapshot the volume.
  2. 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.
  3. 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.