Kanso Documentation

Self-hosting

One image, two variables, your Postgres.

This is the summary an operator needs before the first boot. docs/self-hosting.md in the repository is the long version — the routing table, running your own proxy, and every symptom in full.

What ships

One image holds the front end, the API and a proxy that routes between them, on one origin, for linux/amd64 and linux/arm64. There is no second container to stand up and no service mesh to describe: what a browser reaches is one port.

Postgres is not in it, deliberately. A container that owns your data is a container you cannot restart without thinking about it, and every platform worth deploying to already has a managed Postgres you would rather use. docker/docker-compose.yml ships one beside the image for the case where you have none.

Prepare the database

Kanso creates its own tables — Flyway runs at boot, every boot, and one application container means there is nothing to coordinate. What it cannot create is the role, the database and one extension:

CREATE DATABASE kanso;
CREATE USER kanso WITH PASSWORD '…';
\c kanso
CREATE EXTENSION IF NOT EXISTS pg_trgm;   -- superuser
GRANT ALL ON SCHEMA public TO kanso;      -- Flyway creates the tables

pg_trgm is the line that fails on a managed instance. A migration runs CREATE EXTENSION IF NOT EXISTS pg_trgm itself, so on a Postgres where the kanso role is a superuser you can skip it. On a managed one the role usually is not: RDS, Supabase and Neon all allow it, a locked-down corporate cluster generally needs a DBA to run that one line. The symptom is the container exiting during Flyway with permission denied to create extension "pg_trgm".

Postgres 16 is what is tested. Anything with pg_trgm and a recent enough JDBC dialect will work; nothing in the schema is exotic.

The environment

Two variables are required and everything else has a defensible default or is derived from them.

KANSO_PUBLIC_URL
Required. The URL a browser reaches this instance at, scheme included. The CORS policy, the WebSocket handshake check and every OAuth redirect URI are derived from it, which is why getting it wrong shows up as three unrelated-looking symptoms.
SPRING_DATASOURCE_URL
Required, with _USERNAME and _PASSWORD. The host is resolved from inside the container: a service name on Compose, host.docker.internal for a Postgres on this machine but outside Docker, and never localhost.
KANSO_TLS
auto or off. Defaults to auto when the public URL is https: — see below.
KANSO_SECRET_KEY
The AES key encrypting the secrets the setup wizard stored. Generated into the volume on first boot if unset. Setting it explicitly is half of what makes the container stateless.
KANSO_AUTH_MODE
dev makes identity come from an unverified X-Kanso-User header. It exists for tests and CI, has to be asked for explicitly, logs a loud warning, and disables the agent door entirely. Never expose an instance running that way.

Everything else Kanso reads — NOTION_*, GOOGLE_*, GITHUB_*, the webhook and rate-limit settings — is optional and passes straight through. Anything set here wins over the setup wizard and is shown read-only in it.

TLS, and who the edge is

auto — Caddy inside the image obtains and renews a Let's Encrypt certificate for the host in the public URL. Port 80 has to be reachable from the public internet for the challenge, not only 443. This is the right answer if the edge is not otherwise under your control.

off — your own proxy terminates TLS and Kanso serves plain HTTP behind it. The public URL still says https://…: it describes what the browser uses, not what the container listens on.

Under off, X-Forwarded-For becomes your job. Caddy preserves the inbound header verbatim, because your proxy is the edge — so your proxy has to drop any header it did not set, or a client can name its own address. Under auto, Caddy is the edge and discards it for you.

What /data holds, and what to back up

Two things that must survive a restart. Nothing else — tickets, documents and sessions are all in Postgres.

/data/secret.key
The AES key encrypting the Notion token and the OAuth client secrets the wizard stored. Lose it and those secrets have to be entered again.
Caddy's ACME state
The certificate and account key, under KANSO_TLS=auto. Lose it and every restart asks Let's Encrypt for a new certificate.

The container is stateless — no volume at all — when you set KANSO_SECRET_KEY explicitly and run KANSO_TLS=off. Both reasons for /data are gone, and what is left is an image and two environment variables, which is the shape ECS, Fly or a Kubernetes Deployment wants. Aim for that if you have one.

So a backup is: your Postgres, and either /data/secret.key or the value of KANSO_SECRET_KEY kept wherever you keep secrets.

Running it

Upgrading is pulling a newer image and restarting it. Flyway runs at every boot and brings the schema forward; there is no separate migration step to remember and no maintenance window to declare for one application container.

Sessions are held in memory, so more than one API instance needs a shared session store — one property, with spring-session-jdbc. Until then, run one.

/actuator/health answers liveness, readiness and the database check, which is what a platform's health probe should point at. The entrypoint also probes the database host before starting the JVM and exits in about ten seconds with a message naming what it could not reach, rather than failing deep inside a stack trace a minute later.

When it does not come up

Exits in a second
KANSO_PUBLIC_URL is not set. It says what to set.
Exits in ten seconds
cannot reach … — the entrypoint probed the database host and got nothing. On Compose that host is the service name; outside Docker on this machine it is host.docker.internal, never localhost.
Exits during Flyway
password authentication failed means the host answered, so the URL is right and the credentials are not. permission denied to create extension is pg_trgm, above.
Signed in, but nothing updates by itself
The WebSocket handshake was rejected. The Origin header is compared to the public URL exactly — the same cause as a Google sign-in returning redirect_uri_mismatch, and usually a proxy not sending X-Forwarded-Proto: https.
No certificate under auto
Port 80 must be reachable from the public internet for the ACME challenge, not only 443.

The container prints the address Caddy bound and the KANSO_TLS it resolved on one line before anything else, which is the first thing to read when the symptom is ambiguous.