Serve Kairos on a tailnet
This guide shows how to run the compose deployment so that only your
Tailscale tailnet can reach it. The result is
https://kairos.<tailnet>.ts.net/, with a valid certificate, and no port open on
the host or the local network.
This is the right shape for a small team, a home lab, or one person. Nothing is public. Every device that reaches Kairos is a device you admitted to the tailnet.
Before you start
Section titled “Before you start”You need:
- A tailnet, and admin access to its console.
- Docker on the host, with Docker Compose.
- A clone of the repository, or a copy of the
deploy/directory.
Identity: a tailnet deployment can use your OIDC issuer, or local accounts, or both. This guide uses local accounts, because a tailnet has no wildcard DNS and often no identity provider. See Use local accounts for the first admin.
How it works
Section titled “How it works”The stack adds two containers when you enable the tailscale profile:
tailscale: a Tailscale node that shares the Kairos container’s network namespace. It terminates HTTPS with a certificate for the node’s name and proxies to Kairos on the loopback interface.tailscale-http-redirect: a small nginx that answers plain HTTP with a redirect to HTTPS.tailscale servedoes not tell Kairos which port a request used. So port 80 must land somewhere that is not Kairos.
The Kairos port is never published. Caddy does not start. tailscale serve is a
tailnet-only proxy; the stack never enables Funnel, which would make the
deployment public.
Kairos compresses its own answers (gzip and brotli) when the browser asks for it. The proxy does not have to.
Turn on HTTPS certificates for the tailnet
Section titled “Turn on HTTPS certificates for the tailnet”Step 1. Open the admin console. Go to DNS.
Step 2. Make sure MagicDNS is on.
Step 3. Under HTTPS Certificates, click Enable HTTPS.
Without this step the serve configuration matches no hostname, and the name
answers nothing. The node’s name becomes kairos.<tailnet>.ts.net, where
<tailnet> is the name the console shows you.
Make an auth key
Section titled “Make an auth key”Step 1. In the admin console, go to Settings, then Keys.
Step 2. Click Generate auth key. One use is enough. The node keeps its identity in a Docker volume, so it does not use the key again.
Step 3. Copy the key. It starts with tskey-auth-.
Configure the deployment
Section titled “Configure the deployment”Step 1. Copy the example environment file:
cp deploy/.env.example deploy/.envStep 2. In deploy/.env, select the tailnet front door and give it the key:
COMPOSE_PROFILES=tailscaleTAILSCALE_AUTHKEY=tskey-auth-...TAILSCALE_HOSTNAME=kairosTAILSCALE_HOSTNAME is the first label of the address you will browse to. Leave
KAIROS_SITE_ADDRESS empty. The caddy profile is off, so nothing reads it.
Step 3. Set the tenant. A tailnet has no wildcard DNS, so pin one tenant instead of a base domain:
KAIROS_BASE_DOMAIN=KAIROS_SINGLE_TENANT=acmeStep 4. Turn on local accounts and the first-boot admin, as Use local accounts describes:
KAIROS_LOCAL_AUTH=trueKAIROS_BOOTSTRAP_ADMIN=you@example.comKAIROS_BOOTSTRAP_PASSWORD_HASH=<hash>OIDC_ISSUER_URL=OIDC_AUDIENCE=To make the hash without a running deployment:
docker run --rm ghcr.io/colliery-io/kairos:0.5.0 hash-passwordStep 5. Set POSTGRES_PASSWORD to a long random value.
Start the deployment
Section titled “Start the deployment”docker compose -f deploy/docker-compose.yaml --env-file deploy/.env up -dStep 1. Watch the node join:
docker compose -f deploy/docker-compose.yaml --env-file deploy/.env logs -f tailscaleWait for a line that contains Success. The first HTTPS request can take some
seconds while the node gets its certificate.
Step 2. Check the serve configuration:
docker compose -f deploy/docker-compose.yaml --env-file deploy/.env exec tailscale tailscale serve statusBoth listeners must say (tailnet only):
https://kairos.<tailnet>.ts.net (tailnet only)|-- / proxy http://127.0.0.1:8080
http://kairos.<tailnet>.ts.net (tailnet only)|-- / proxy http://127.0.0.1:8081If a line says (Funnel on), stop. Something outside this stack enabled Funnel,
and the deployment is public. Run tailscale funnel off in the container.
Step 3. In the admin console, open the new node and Disable key expiry. Without this, the key expires, the node logs out, and the address stops answering.
Step 4. From a device on the tailnet, open https://kairos.<tailnet>.ts.net/.
Sign in as the first admin, then remove the two bootstrap variables from
deploy/.env.
Provision the tenant
Section titled “Provision the tenant”The tenant that KAIROS_SINGLE_TENANT names does not exist yet. When a
deployment admin logs in, the server creates it and makes that admin its first
admin. To give it a name other than its slug, set KAIROS_SINGLE_TENANT_NAME
before you log in. Nothing else is necessary.
To create it before anyone logs in, use the command line:
docker compose -f deploy/docker-compose.yaml --env-file deploy/.env exec kairos kairos-server create-tenant --slug acme --name "Acme Inc"Then make the first admin a member of it, as Provision a tenant describes.
After you recreate the Kairos container
Section titled “After you recreate the Kairos container”The two sidecars share the Kairos container’s network namespace. When you
recreate that container, for example to change KAIROS_VERSION, the sidecars
keep the old namespace. The address stops answering. Recreate them
too:
docker compose -f deploy/docker-compose.yaml --env-file deploy/.env up -d --force-recreate --no-deps tailscale tailscale-http-redirectdocker compose up -d after a change to deploy/.env does this for you, because
compose sees that the dependency changed.
What this deployment does not do
Section titled “What this deployment does not do”- It does not serve subdomain tenants. MagicDNS has no wildcard, so
acme.kairos.<tailnet>.ts.netresolves to nothing. UseKAIROS_SINGLE_TENANT. - It does not reach the public internet. A person who is not on the tailnet gets no answer at all, not a login page.
- It does not replace backups. The Postgres volume is the data; the
tailscale_statevolume is the node’s identity. Back up both. See Back up and restore.