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
_USERNAMEand_PASSWORD. The host is resolved from inside the container: a service name on Compose,host.docker.internalfor a Postgres on this machine but outside Docker, and neverlocalhost. - KANSO_TLS
autooroff. Defaults toautowhen the public URL ishttps:— 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
devmakes identity come from an unverifiedX-Kanso-Userheader. 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 ishost.docker.internal, neverlocalhost.- Exits during Flyway
password authentication failedmeans the host answered, so the URL is right and the credentials are not.permission denied to create extensionispg_trgm, above.- Signed in, but nothing updates by itself
- The WebSocket handshake was rejected. The
Originheader is compared to the public URL exactly — the same cause as a Google sign-in returningredirect_uri_mismatch, and usually a proxy not sendingX-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.