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. Both the free tier and Self-host Team are governed by the self-host licence terms.
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 2.17 or later on a single host, or an equivalent container runtime. The bundled stack builds one small setup image locally, which needs 2.17.
- 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, Garage, or anything else that speaks the S3 API. Garage 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 (
KYTHENE_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, its own Postgres, a Garage object store, and a one-shot garage-setup container that applies Garage's layout, creates the bucket and grants the app's key read and write on it. It's three files in one directory.
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. The MINIO in those names is historical: they're the app's settings for any S3-compatible store.
Save this as docker-compose.yml. Everything after garage-setup is optional - the one-off copy for installs upgrading from the old MinIO bundle (see Upgrading from the MinIO bundle) and a local embedding server for semantic recall - and stays off unless you name its profile.
# Kythene self-host - single-node stack.
#
# Kythene (the app) plus its own Postgres and Garage object store. Put a
# TLS-terminating reverse proxy (Caddy, nginx, Traefik, Cloudflare, ...) in
# front of the app's HTTP port - the app speaks plain HTTP and never
# terminates TLS itself. Do not expose the app port directly to the internet.
#
# Copy .env.example to .env, fill it in, then:
#
# docker compose up -d
#
# Migrations run automatically on boot; the object store's layout, bucket and
# access key are set up by the one-shot `garage-setup` service (the app does not
# create its own bucket).
#
# Upgrading from the earlier bundle, which ran MinIO? Your files are in the
# `miniodata` volume and have to be copied across once - see "Upgrading from the
# MinIO bundle" in the self-host guide. garage-setup refuses to start the app on
# an empty store while that volume still holds them.
services:
app:
image: ${KYTHENE_IMAGE:-ghcr.io/kythene/app:latest}
restart: unless-stopped
env_file: .env
depends_on:
postgres:
condition: service_healthy
# garage-setup completing implies Garage is up AND the bucket and key
# exist, so the app needs only this (plus Postgres).
garage-setup:
condition: service_completed_successfully
environment:
# Listen port inside the container (also EXPOSEd by the image).
KYTHENE_PORT: ${KYTHENE_PORT:-8080}
# Plain HTTP: TLS is your reverse proxy's job. The app's own default is
# to serve TLS, which a proxy speaking HTTP to it can't talk to.
KYTHENE_TLS: none
# Point the app at the bundled Postgres and Garage services. These
# override anything in .env so the wiring stays correct for this stack.
# (The KYTHENE_MINIO_* names are the app's settings for any S3-compatible
# store, Garage included.)
KYTHENE_DB: postgres://${POSTGRES_USER:-kythene}:${POSTGRES_PASSWORD:-kythene}@postgres:5432/${POSTGRES_DB:-kythene}?sslmode=disable&search_path=public
KYTHENE_MINIO_ENDPOINT: garage:3900
KYTHENE_MINIO_ACCESS_KEY: ${GARAGE_KEY_ID:-kythene-key1}
KYTHENE_MINIO_SECRET_KEY: ${GARAGE_KEY_SECRET:?set GARAGE_KEY_SECRET in .env}
KYTHENE_MINIO_BUCKET: ${KYTHENE_MINIO_BUCKET:-kythene}
KYTHENE_MINIO_USE_SSL: "false"
KYTHENE_MINIO_REGION: us-east-1
volumes:
# Writable volume for the auto-refreshed licence key
# (KYTHENE_LICENCE_KEY_FILE=/data/licence.key) so a renewed key survives
# restarts and image upgrades.
- appdata:/data
ports:
- "${KYTHENE_PORT:-8080}:${KYTHENE_PORT:-8080}" # front this with a TLS-terminating proxy
postgres:
# pgvector image: ships the `vector` extension the app's semantic recall
# needs. The app connects as POSTGRES_USER, which is the container's
# superuser, so the boot migration's `CREATE EXTENSION vector` succeeds.
image: pgvector/pgvector:pg18
restart: unless-stopped
environment:
POSTGRES_USER: ${POSTGRES_USER:-kythene}
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-kythene}
POSTGRES_DB: ${POSTGRES_DB:-kythene}
volumes:
# The parent directory, not .../data: from Postgres 18 the image keeps
# its data in a versioned subdirectory (/var/lib/postgresql/18/docker) and
# refuses to start with a volume on the old path.
- pgdata:/var/lib/postgresql
healthcheck:
test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER:-kythene} -d ${POSTGRES_DB:-kythene}"]
interval: 10s
timeout: 5s
retries: 10
start_period: 20s
garage:
# Pinned by tag AND digest: a vendor withdrawing a tag is how the MinIO
# images this bundle used to run stopped pulling.
image: dxflrs/garage:v2.4.1@sha256:9c96caa2612d3411acc5b0e6701fb238dbfba33e533a6d7d3d811a4b12d0d020
restart: unless-stopped
environment:
GARAGE_RPC_SECRET: ${GARAGE_RPC_SECRET:?set GARAGE_RPC_SECRET in .env}
GARAGE_ADMIN_TOKEN: ${GARAGE_ADMIN_TOKEN:?set GARAGE_ADMIN_TOKEN in .env}
volumes:
- ./garage.toml:/etc/garage.toml:ro
- garagedata:/var/lib/garage
# No ports published: only the app (S3, :3900) and garage-setup (admin,
# :3903) talk to it, over the compose network. There's no web console;
# use `docker compose exec garage /garage <command>`.
# Exec form: the image has no shell, so a string-form check can never run.
healthcheck:
test: ["CMD", "/garage", "status"]
interval: 10s
timeout: 5s
retries: 5
garage-setup:
# One-shot init on every `up`, idempotent: applies the node's layout,
# creates the bucket, imports the app's key and grants it read+write on that
# bucket only (see provision-garage.sh). Built locally, once, from a pinned
# Alpine with curl and jq, so later runs need no network.
build:
dockerfile_inline: |
FROM alpine:3.22@sha256:5291449c3df73caf6ed85e649dec1b9e818b39a5d8c871e97afc13e9cd5e8fa8
RUN apk add --no-cache curl jq
depends_on:
garage:
condition: service_healthy
environment: &garage-setup-env
GARAGE_ADMIN: http://garage:3903
GARAGE_ADMIN_TOKEN: ${GARAGE_ADMIN_TOKEN:?set GARAGE_ADMIN_TOKEN in .env}
GARAGE_ZONE: kythene
# Only weights nodes against each other, so on one node any figure works.
GARAGE_CAPACITY: "1000000000000"
BUCKET: ${KYTHENE_MINIO_BUCKET:-kythene}
KEY_ID: ${GARAGE_KEY_ID:-kythene-key1}
KEY_SECRET: ${GARAGE_KEY_SECRET:?set GARAGE_KEY_SECRET in .env}
SKIP_MINIO_CHECK: ${KYTHENE_ABANDON_MINIO_DATA:-}
entrypoint: ["sh", "/setup/provision-garage.sh"]
volumes:
- ./provision-garage.sh:/setup/provision-garage.sh:ro
# Read-only, to catch an upgrade from the MinIO bundle that skipped the
# data migration. Empty on a fresh install.
- miniodata:/minio:ro
restart: "no"
# ---------------------------------------------------------------------------
# UPGRADING FROM THE MINIO BUNDLE (profile "minio-migrate") - once only
#
# Copies every object from the old MinIO volume into Garage, then checks each
# one byte for byte. See "Upgrading from the MinIO bundle" in the self-host guide:
#
# docker compose down --remove-orphans # stops the old bundle's minio too
# docker compose --profile minio-migrate run --rm minio-migrate
# docker compose --profile minio-migrate rm -sf minio-old garage-setup-migrate
# docker compose up -d
#
# MinIO no longer publishes images, so minio-old runs the copy already in your
# Docker cache. If it isn't there, any MinIO build that reads the same volume
# will do: set KYTHENE_OLD_MINIO_IMAGE (e.g. chainguard/minio).
minio-old:
image: ${KYTHENE_OLD_MINIO_IMAGE:-minio/minio}
profiles: ["minio-migrate"]
command: server /data
environment:
MINIO_ROOT_USER: ${MINIO_ROOT_USER:-kythene}
MINIO_ROOT_PASSWORD: ${MINIO_ROOT_PASSWORD:-changeme-minio}
volumes:
- miniodata:/data
# garage-setup without the check above - the copy is what clears it.
garage-setup-migrate:
extends: garage-setup
profiles: ["minio-migrate"]
environment:
<<: *garage-setup-env
SKIP_MINIO_CHECK: "1"
minio-migrate:
image: rclone/rclone:1.75.1@sha256:45401ad7410db1d67ffdb58e19059ad20b0d8e0285a60e38bbec55cc1019c7a5
profiles: ["minio-migrate"]
depends_on:
minio-old:
condition: service_started
garage-setup-migrate:
condition: service_completed_successfully
environment:
BUCKET: ${KYTHENE_MINIO_BUCKET:-kythene}
RCLONE_CONFIG_OLD_TYPE: s3
RCLONE_CONFIG_OLD_PROVIDER: Minio
RCLONE_CONFIG_OLD_ENDPOINT: http://minio-old:9000
RCLONE_CONFIG_OLD_ACCESS_KEY_ID: ${MINIO_ROOT_USER:-kythene}
RCLONE_CONFIG_OLD_SECRET_ACCESS_KEY: ${MINIO_ROOT_PASSWORD:-changeme-minio}
RCLONE_CONFIG_NEW_TYPE: s3
RCLONE_CONFIG_NEW_PROVIDER: Other
RCLONE_CONFIG_NEW_ENDPOINT: http://garage:3900
RCLONE_CONFIG_NEW_REGION: us-east-1
RCLONE_CONFIG_NEW_ACCESS_KEY_ID: ${GARAGE_KEY_ID:-kythene-key1}
RCLONE_CONFIG_NEW_SECRET_ACCESS_KEY: ${GARAGE_KEY_SECRET:?set GARAGE_KEY_SECRET in .env}
# The app's key may use its bucket but not create one; garage-setup has.
RCLONE_CONFIG_NEW_NO_CHECK_BUCKET: "true"
# -M (--metadata) is not optional: without it every object loses its stored
# Content-Disposition, so downloads lose their filenames - with no error.
# copy rather than sync, so a re-run never deletes anything the app has
# since written to Garage. The check is one-way for the same reason, and
# --download because multipart objects have no comparable hash.
entrypoint:
- sh
- -c
- |
set -eu
i=0
until rclone lsd old: >/dev/null 2>&1; do
i=$$((i + 1)); [ "$$i" -lt 60 ] || { echo "minio-old not reachable after 120s" >&2; exit 1; }
sleep 2
done
rclone copy -M --transfers 8 --stats-one-line --stats 10s "old:$$BUCKET" "new:$$BUCKET"
rclone check --download --one-way "old:$$BUCKET" "new:$$BUCKET"
echo "==> every object in old:$$BUCKET is in Garage, byte for byte"
restart: "no"
# ---------------------------------------------------------------------------
# OPTIONAL: semantic recall (profile "semantic")
#
# Off by default - Kythene works without it, using full-text recall. Turn it on
# to also match on meaning rather than only on words:
#
# docker compose --profile semantic up -d
# docker compose --profile semantic run --rm embeddings-pull # once, ~1.2GB
#
# then set these in .env and restart the app:
# KYTHENE_MEMORY_EMBED_URL=http://embeddings:11434
# KYTHENE_MEMORY_EMBED_MODEL=bge-m3
# KYTHENE_MEMORY_EMBED_DIMS=1024
#
# This runs ENTIRELY on your own infrastructure - no text is sent to any
# third-party API, and the container needs no internet access once the model is
# pulled (so it works air-gapped). CPU only: these are small encoder models,
# not LLMs. Budget ~2GB RAM and a couple of cores.
#
# bge-m3 is the default because it requires no query/document instruction
# prefixes - with models that do (nomic-embed-text, bge-*-en, e5), getting the
# prefix wrong quietly degrades results instead of failing. Its 1024 dimensions
# are fixed once you have embedded anything: changing model later means
# re-embedding everything.
embeddings:
image: ollama/ollama:latest
profiles: ["semantic"]
restart: unless-stopped
volumes:
- ollamadata:/root/.ollama
healthcheck:
test: ["CMD-SHELL", "ollama list >/dev/null 2>&1 || exit 1"]
interval: 30s
timeout: 10s
retries: 5
start_period: 30s
# One-shot model download. Idempotent - a no-op once the model is present.
embeddings-pull:
image: ollama/ollama:latest
profiles: ["semantic"]
depends_on:
embeddings:
condition: service_started
environment:
OLLAMA_HOST: http://embeddings:11434
entrypoint: ["sh", "-c", "ollama pull ${KYTHENE_MEMORY_EMBED_MODEL:-bge-m3}"]
restart: "no"
volumes:
appdata:
pgdata:
garagedata:
# The old MinIO bundle's store. Read by garage-setup's upgrade check and by
# the minio-migrate profile; empty on a fresh install. Remove it once the
# migration is verified (see the self-host guide).
miniodata:
# only used with the "semantic" profile
ollamadata:Save this beside it as garage.toml - Garage's own configuration, with no secrets in it:
# Garage - the bundled S3-compatible object store. Single node, no redundancy.
# Secrets (GARAGE_RPC_SECRET, GARAGE_ADMIN_TOKEN) come from .env via the
# compose file, so nothing secret lives here.
metadata_dir = "/var/lib/garage/meta"
data_dir = "/var/lib/garage/data"
db_engine = "lmdb"
replication_factor = 1
# Flush metadata to disk on every write. Slower, but a power cut can't corrupt
# the metadata database. Data blocks are content-addressed and checksummed, so
# they don't need it.
metadata_fsync = true
data_fsync = false
metadata_auto_snapshot_interval = "6h"
rpc_bind_addr = "[::]:3901"
rpc_public_addr = "127.0.0.1:3901"
[s3_api]
# Not Garage's default "garage": rclone, the aws CLI and most SDKs sign for
# us-east-1 unless told otherwise, and a region mismatch fails as a signature
# error.
s3_region = "us-east-1"
api_bind_addr = "[::]:3900"
[admin]
# Reached only from inside the compose network, by garage-setup.
api_bind_addr = "[::]:3903"And this as provision-garage.sh, which is what garage-setup runs on every up. It's idempotent, and reads the grant back rather than trusting a 200:
#!/bin/sh
# Idempotent Garage provisioning for the Kythene self-host stack, over Garage's
# admin API: the cluster layout, then the app's bucket, its access key and a
# read/write grant. Runs on every `docker compose up` as the garage-setup
# one-shot, and the app doesn't start until it has succeeded.
#
# Adapted from the provisioning we run on our own servers; the same rules hold:
# fail loudly on anything unexpected, and read the grant back rather than
# trusting a 200.
#
# GARAGE_ADMIN admin API base URL (http://garage:3903)
# GARAGE_ADMIN_TOKEN admin bearer token
# GARAGE_ZONE layout zone name
# GARAGE_CAPACITY layout capacity in bytes. Garage only uses it to weight
# nodes, so on a single node any figure works.
# BUCKET the app's bucket
# KEY_ID / KEY_SECRET the app's access key: id 8+ chars, secret 16+. Garage
# never lets an id be reused, even after the key is
# deleted, and can't change a key's secret - so rotating
# means a NEW id as well as a new secret.
# MINIO_DIR where the old MinIO volume is mounted (read-only), to
# catch an upgrade that skipped the data migration
# SKIP_MINIO_CHECK 1 = skip that check (the migration itself, or an
# operator who chose to leave the old data behind)
set -eu
die() { echo "garage-setup: $*" >&2; exit 1; }
: "${GARAGE_ADMIN:?}" "${GARAGE_ADMIN_TOKEN:?}" "${GARAGE_ZONE:?}" "${GARAGE_CAPACITY:?}"
: "${BUCKET:?}" "${KEY_ID:?}" "${KEY_SECRET:?}"
case "$BUCKET" in
*[!a-z0-9-]* | -* | *- | ? | ??) die "$BUCKET: invalid bucket name (a-z, 0-9 and -, 3+ chars, no leading/trailing -)" ;;
esac
[ "${#KEY_ID}" -ge 8 ] || die "GARAGE_KEY_ID must be at least 8 characters"
[ "${#KEY_SECRET}" -ge 16 ] || die "GARAGE_KEY_SECRET must be at least 16 characters"
MINIO_DIR=${MINIO_DIR:-/minio}
# api METHOD PATH [JSON] - sets CODE and BODY. Never exits: an unreachable API
# is CODE=000, so the readiness loop can retry it. Callers decide what each
# status means.
api() {
CODE=$(curl -sS --max-time 10 -o /tmp/garage-body -w '%{http_code}' -X "$1" \
-H "Authorization: Bearer $GARAGE_ADMIN_TOKEN" -H 'Content-Type: application/json' \
${3:+--data "$3"} "$GARAGE_ADMIN$2" 2>/dev/null) || CODE=000
BODY=$(cat /tmp/garage-body 2>/dev/null || true)
}
must() { # must METHOD PATH [JSON] - api, and anything but 200 is fatal
api "$@"
[ "$CODE" = 200 ] || die "$1 $2 -> HTTP $CODE: $BODY"
}
i=0
until api GET /v2/GetClusterStatus; [ "$CODE" = 200 ]; do
case "$CODE" in 401|403) die "admin token rejected (HTTP $CODE) - check GARAGE_ADMIN_TOKEN" ;; esac
i=$((i + 1)); [ "$i" -lt 60 ] || die "garage admin API not ready after 60s (last HTTP $CODE)"
sleep 1
done
# --- layout: a fresh node serves nothing until it has a role -----------------
must GET /v2/GetClusterLayout
if [ "$(printf '%s\n' "$BODY" | jq -r .version)" = 0 ]; then
must GET /v2/GetClusterStatus
[ "$(printf '%s\n' "$BODY" | jq '.nodes | length')" = 1 ] || die "expected exactly one node, got: $(printf '%s\n' "$BODY" | jq -c '[.nodes[].id]')"
node=$(printf '%s\n' "$BODY" | jq -r '.nodes[0].id')
must POST /v2/UpdateClusterLayout "$(jq -nc --arg id "$node" --arg z "$GARAGE_ZONE" --argjson c "$GARAGE_CAPACITY" \
'{roles: [{id: $id, zone: $z, capacity: $c, tags: []}]}')"
must POST /v2/ApplyClusterLayout '{"version":1}'
echo "==> garage: layout applied"
fi
# --- bucket ------------------------------------------------------------------
api GET "/v2/GetBucketInfo?globalAlias=$BUCKET"
case "$CODE" in
200) ;;
404) must POST /v2/CreateBucket "$(jq -nc --arg b "$BUCKET" '{globalAlias: $b}')" ;;
*) die "$BUCKET: GetBucketInfo -> HTTP $CODE: $BODY" ;;
esac
bid=$(printf '%s\n' "$BODY" | jq -r .id)
# --- an upgrade from the MinIO bundle that skipped the data migration ---------
# Without this the app would start on an empty store, and every file published
# before the upgrade would look lost. Checked on every run, not just the first:
# an empty Garage bucket beside a non-empty MinIO one is never what anyone meant.
if [ -d "$MINIO_DIR/$BUCKET" ] && [ -n "$(find "$MINIO_DIR/$BUCKET" -mindepth 1 -print -quit)" ] &&
[ "$(printf '%s\n' "$BODY" | jq -r '.objects // 0')" = 0 ] && [ "${SKIP_MINIO_CHECK:-}" != 1 ]; then
die "the old MinIO volume holds objects in bucket $BUCKET, but Garage's bucket is empty.
Copy them across before starting the app - see 'Upgrading from the MinIO bundle' in the Kythene self-host guide:
docker compose --profile minio-migrate run --rm minio-migrate
(To start on an empty store and leave that data behind, set KYTHENE_ABANDON_MINIO_DATA=1.)"
fi
# --- key ---------------------------------------------------------------------
api GET "/v2/GetKeyInfo?id=$KEY_ID&showSecretKey=true"
case "$CODE" in
200)
# Garage can't change a key's secret, so an existing key with a different
# one means .env and the store disagree. Silently keeping the old secret
# would leave the app on a credential nobody has: stop instead.
[ "$(printf '%s\n' "$BODY" | jq -r .secretAccessKey)" = "$KEY_SECRET" ] ||
die "key $KEY_ID exists with a DIFFERENT secret - to rotate, set GARAGE_KEY_ID to a new id as well"
;;
404) must POST /v2/ImportKey "$(jq -nc --arg id "$KEY_ID" --arg s "$KEY_SECRET" --arg n "$BUCKET" \
'{accessKeyId: $id, secretAccessKey: $s, name: $n}')" ;;
*) die "GetKeyInfo -> HTTP $CODE: $BODY" ;;
esac
# Read and write on this bucket only - never owner; the app doesn't need it.
must POST /v2/AllowBucketKey "$(jq -nc --arg b "$bid" --arg k "$KEY_ID" \
'{bucketId: $b, accessKeyId: $k, permissions: {read: true, write: true, owner: false}}')"
# Read it back. A 200 from AllowBucketKey is not evidence the grant stuck.
must GET "/v2/GetBucketInfo?globalAlias=$BUCKET"
printf '%s\n' "$BODY" | jq -e --arg k "$KEY_ID" 'any(.keys[]; .accessKeyId == $k and .permissions.read and .permissions.write)' >/dev/null ||
die "key $KEY_ID has no read+write grant on $BUCKET after provisioning"
# Other keys still granted - normally the previous key after a rotation. Left
# alone because something may still hold it; revoke it once nothing does.
others=$(printf '%s\n' "$BODY" | jq -r --arg k "$KEY_ID" '[.keys[] | select(.accessKeyId != $k) | .accessKeyId] | join(" ")')
[ -z "$others" ] || echo " WARNING: $BUCKET is also granted to: $others - revoke once nothing uses it:
docker compose exec garage /garage bucket deny --read --write $BUCKET --key <id>"
echo "==> garage: bucket $BUCKET ready (key $KEY_ID)"Then save this as .env and fill it in (every setting is documented in Configuration below). Fill in the four empty secrets with openssl rand -hex 32 each: the stack won't start without the three Garage ones, and without KYTHENE_SETUP_TOKEN it starts but the first-run wizard stays locked:
# Required
KYTHENE_ENV=prod
KYTHENE_URL=https://kythene.example.com # the exact URL you reach the app on, no trailing slash
KYTHENE_PORT=8080
KYTHENE_SETUP_TOKEN= # gates first-run /setup; without it the wizard is locked (fail-closed)
# Bundled Postgres - CHANGE THE PASSWORD before first run
POSTGRES_USER=kythene
POSTGRES_PASSWORD=changeme-postgres
POSTGRES_DB=kythene
# Bundled Garage
GARAGE_RPC_SECRET=
GARAGE_ADMIN_TOKEN=
GARAGE_KEY_SECRET= # 16+ chars; fixed for GARAGE_KEY_ID once first used
GARAGE_KEY_ID=kythene-key1 # 8+ chars
KYTHENE_MINIO_BUCKET=kytheneKeep the Garage secrets safe. Garage can't change a key's secret, so GARAGE_KEY_SECRET is fixed for GARAGE_KEY_ID once first used; to rotate it, set a new GARAGE_KEY_ID (e.g. kythene-key2) with a new secret, and garage-setup refuses to start the app if the two ever disagree.
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_AUTO_RENEW=false # auto-renew is ON by default; false turns it off for a fully offline installThen bring it up:
docker compose up -dOn boot the app connects to Postgres, applies migrations forward (idempotent), and starts serving. garage-setup runs first and the app waits for it to succeed - the app does not create its own bucket. Point your TLS proxy at the app's port (8080 by default) and open your KYTHENE_URL.
Garage has no web console and publishes no ports; it's reachable only on the compose network. Inspect it with docker compose exec garage /garage <command>, e.g. bucket info kythene.
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). KYTHENE_SETUP_TOKEN, KYTHENE_LICENCE_KEY, KYTHENE_ANTHROPIC_APIKEY and KYTHENE_MEMORY_EMBED_APIKEY can also be given as file:/absolute/path, and the file's contents are used - handy for Docker/Kubernetes secrets. Any other value, including one starting with /, is used as-is, and the other settings don't read files at all.
Required
| Key | Example | Purpose |
|---|---|---|
KYTHENE_ENV | prod | Always prod for a real install. The app refuses to start without it. |
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_TLS | none | Must be none behind a TLS proxy. The app's own default is to serve TLS from a supplied certificate, which a proxy speaking plain HTTP to it can't talk to. The bundled compose sets it. |
KYTHENE_DB | postgres://user:pass@host:5432/kythene?sslmode=disable&search_path=public | Postgres DSN |
KYTHENE_MINIO_ENDPOINT | garage:3900 | Object store host:port, for any S3-compatible store (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 - in the bundle, garage-setup does) |
KYTHENE_MINIO_USE_SSL | false | true for a TLS object-store endpoint |
KYTHENE_MINIO_REGION | (blank) | S3 region, if your provider needs one. The bundled Garage uses us-east-1. |
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 file:/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_AUTO_RENEW | true | On by default. While a licence is held, the instance fetches a refreshed key from the storefront as expiry approaches, so an active subscription never lapses in place. false is the only way to turn it off: no outbound calls, offline re-validation only, and you paste a renewed key by hand. |
KYTHENE_LICENCE_REFRESH_URL | https://www.kythene.com | The storefront auto-renew fetches from. Leave it at the default. A blank value is ignored - it falls back to the default and does not turn renewal off; use KYTHENE_LICENCE_AUTO_RENEW=false for that. |
KYTHENE_LICENCE_REFRESH_INTERVAL | 12h | How often the instance re-validates its licence offline and, with auto-renew on, checks for a refreshed key. |
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. The free tier is licensed for one person whatever this is set to - more than one signed-in human needs a licence (self-host licence terms, section 15). |
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 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. |
KYTHENE_PRIVACY_URL / KYTHENE_TERMS_URL | Your own privacy policy and terms of use, linked from the footer. Blank (the default) hides the link: a self-managed install serves no legal pages of its own, so there is nothing for it to point at. |
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 agent 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 agent 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.
The self-host licence terms govern use of a self-hosted instance, free tier included; this section covers the practical side - applying a key, the writable key file, and turning auto-renew off.
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. It also auto-renews from the storefront, so an active subscription never lapses in place, unless you setKYTHENE_LICENCE_AUTO_RENEW=false. - 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; the one licensing call that leaves the instance is the renewal fetch, which is on by default and only made while a licence is held and nearing expiry. An air-gapped install sets KYTHENE_LICENCE_AUTO_RENEW=false and re-validates the key it already holds, making no outbound calls, and you paste a renewed key in by hand. Blanking KYTHENE_LICENCE_REFRESH_URL does not do this - a blank value is ignored and the default storefront is used. (Before app v0.143.0, renewal was off unless KYTHENE_LICENCE_REFRESH_URL was set; upgrading an install that relied on that turns renewal on.)
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.
Upgrading from the MinIO bundle
Earlier versions of this guide bundled MinIO. MinIO has stopped publishing its images, so that stack can't be installed fresh any more, and Garage replaces it. Your files are still in the old miniodata volume and have to be copied into Garage once. Until they are, garage-setup refuses to start the app on an empty store - otherwise everything published before the upgrade would look lost.
Take your backups (below). Then replace
docker-compose.ymlwith the one above and addgarage.tomlandprovision-garage.shbeside it.In
.env, addGARAGE_RPC_SECRET,GARAGE_ADMIN_TOKENandGARAGE_KEY_SECRET(andGARAGE_KEY_IDif you want other thankythene-key1), and keep yourMINIO_ROOT_USER/MINIO_ROOT_PASSWORD- the copy reads the old store with them.Copy everything across, with the old stack stopped:
docker compose down --remove-orphans docker compose --profile minio-migrate run --rm minio-migrate docker compose --profile minio-migrate rm -sf minio-old garage-setup-migrate docker compose up -ddownkeeps your volumes.--remove-orphansmatters: without it the old bundle'sminiocontainer, no longer in the compose file, carries on running with its console published on port 9001, on the same volume the copy reads.The copy starts the old MinIO from the image already in your Docker cache, copies every object into Garage with
rclone copy -M, then compares every object byte for byte and fails loudly on any difference. Ifminio/minioisn't in your cache, setKYTHENE_OLD_MINIO_IMAGEto another MinIO build that reads the same volume (chainguard/minioworks).If you copy by hand instead, always pass
-M(--metadata) to rclone. Without it every object loses its stored filename, so downloads come back under their internal ids - with no error.Check a few downloads in the app. Once you're happy, delete the old volume and the
MINIO_ROOT_*lines in.env.garage-setupstill has the volume mounted, so remove that (finished) container first:docker compose rm -f garage-setup docker volume rm <project>_miniodata docker compose up -dThe last
upputs back an emptyminiodata, which is harmless.
Re-running the copy is safe: it never deletes anything the app has written to Garage since. To start on an empty store and leave the old files behind instead, set KYTHENE_ABANDON_MINIO_DATA=1 in .env.
What Garage doesn't do
Garage covers everything Kythene needs, but two things MinIO offered are gone:
- No object versioning. A deleted or overwritten object is gone for good - your backups are the only way back.
- No object lock (WORM). Nothing in Kythene uses it.
If you need either, run the app against a store that has them - any S3-compatible store works.
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. For the bundled Garage, don't copy its live metadata database: take a snapshot first with
docker compose exec garage /garage meta snapshotand back up thegaragedatavolume with that snapshot in it (meta/snapshots/), or stop the stack while you copy it. A restore needsmeta/node_keyandmeta/node_key.pubas well as the data and the snapshot - without them the node comes back with a new identity its own layout doesn't name. - 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.