9.1 KiB
KiteStacks Homelab — Complete Setup Runbook
Last Updated: 2026-06-18
Status: Production (monk primary, kscloud1 Hetzner cloud replica)
Maintainer: kenpat
Architecture Overview
Internet
│
└── Cloudflare (DNS + Tunnel)
│ Active-Active across 2 connectors
├── cloudflared on monk (primary home machine, Docker container)
└── cloudflared on kscloud1 (Hetzner VPS, <KSCLOUD1_PUBLIC_IP>)
Tailscale overlay network (VPN mesh):
monk <MONK_TAILSCALE_IP>
kscloud1 <KSCLOUD1_TAILSCALE_IP> ← hosts shared Authentik Postgres + Redis
Public subdomains route through the same Cloudflare Tunnel token. Both monk and kscloud1 are connectors so the site stays up if either goes offline.
| Subdomain | Service | Port |
|---|---|---|
| auth.kitestacks.com | Authentik | 9000 |
| portainer.kitestacks.com | Portainer | 9443 |
| wiki.kitestacks.com | BookStack | 6875 (monk) / 6877 (kscloud1) |
| grafana.kitestacks.com | Grafana | 3000 |
| gitforge.kitestacks.com | Forgejo | 3006 |
| links.kitestacks.com | Karakeep | 3100 |
| status.kitestacks.com | Uptime Kuma | 3001 |
| tasks.kitestacks.com | OSTicket | 8080 |
| flux.kitestacks.com | FluxCD | — |
Service Inventory
Running on monk
authentik, authentik-worker, authentik-ldap, authentik-ldap-proxy, bookstack, bookstack-db, cloudflared, flux, forgejo, grafana, karakeep, karakeep-chrome, karakeep-meilisearch, kavita, kite-litellm, kite-openwebui, kitestacks-metrics-api, kitestacks-portal, node-exporter, ntfy, osticket, osticket-app, osticket-db, portainer, prometheus, uptime-kuma, blackbox-exporter
Running on kscloud1 (extras)
bookstack, bookstack-db-ks, kite-monitor, osticket-app-118, osticket-db-118, www-backup, homepage-backup, cloudflared, authentik-postgresql, authentik-redis
Shared infrastructure on kscloud1
- PostgreSQL
:5432— Authentik DB used by both hosts (Tailscale only) - Redis
:6379— Authentik session cache (Tailscale only)
Cloudflare Tunnel
How it works
Both monk and kscloud1 run cloudflared as Docker containers using the same tunnel token. Cloudflare load-balances across both connectors (active-active). The tunnel token is stored in:
- monk:
~/kitestacks-live/docker/cloudflared/.env→TUNNEL_TOKEN - kscloud1:
/opt/kitestacks/docker/cloudflared/.env→TUNNEL_TOKEN
Fix: Phantom 3rd Replica
If cloudflared tunnel info shows 3 connectors instead of 2, the native cloudflared systemd service on monk is running alongside the Docker container.
# Check systemd cloudflared on monk
systemctl status cloudflared
# Disable it — Docker container is the correct one
sudo systemctl disable --now cloudflared
Adding a new hostname route
In Cloudflare Zero Trust → Networks → Tunnels → your tunnel → Edit → Public Hostname:
- Subdomain:
newservice - Domain:
kitestacks.com - Service:
http://container-name:port
Both monk and kscloud1 must have the container running on the same port.
Authentik SSO
Architecture
Authentik uses a shared database hosted on kscloud1. monk's Authentik containers connect via Tailscale.
- monk containers:
authentik,authentik-worker,authentik-ldap,authentik-ldap-proxy - DB: PostgreSQL on kscloud1 at
<KSCLOUD1_TAILSCALE_IP>:5432 - Redis: kscloud1 at
<KSCLOUD1_TAILSCALE_IP>:6379
Adding OIDC SSO for a new app
-
In Authentik admin (
https://auth.kitestacks.com/if/admin/):- Providers → Create → OAuth2/OpenID Provider
- Name the provider after the app (e.g.
bookstack) - Set
issuer_modebased on the app's requirements (see Debug doc) - Note the Client ID and Client Secret
-
Application → Create → link to the provider
-
Policy Binding → bind the
default-authentication-flowto the application -
Configure the app with:
OIDC_ISSUER= discovery base URLOIDC_CLIENT_ID/OIDC_CLIENT_SECRET- Callback URL =
https://yourapp.kitestacks.com/auth/callback
Checking OIDC discovery URL
# Per-provider (issuer_mode=per_provider)
curl -s https://auth.kitestacks.com/application/o/<slug>/.well-known/openid-configuration | python3 -m json.tool
# Global (issuer_mode=global)
# Note: global issuer URL does NOT serve a JSON discovery doc at /.well-known/
# Use per-provider mode for apps that auto-discover endpoints (BookStack, etc.)
Changing provider issuer_mode via SQL
docker run --rm --network host \
-e PGPASSWORD="<REDACTED>" \
postgres:16 psql -h <KSCLOUD1_TAILSCALE_IP> -U authentik authentik -c \
"UPDATE authentik_providers_oauth2_oauth2provider SET issuer_mode='per_provider' WHERE provider_ptr_id=<ID>;"
Portainer
OAuth setup (Authentik)
Portainer CE uses AuthenticationMethod=3 (OAuth). Configured via the BoltDB.
Key settings:
OAuthLoginURI:https://auth.kitestacks.com/application/o/authorize/OAuthTokenURI:https://auth.kitestacks.com/application/o/token/OAuthUserURI:https://auth.kitestacks.com/application/o/userinfo/OAuthClientID:portainerOAuthRedirectURI:https://portainer.kitestacks.comOAuthAutoCreateUsers:trueOAuthDefaultTeamID:0
Pre-creating an admin user before first OAuth login
OAuth auto-created users default to Role:2 (regular user) and can't see environments. Pre-create them as Role:1 (admin) via the API before they log in:
# Get auth token
TOKEN=$(curl -sk -X POST https://portainer.kitestacks.com/api/auth \
-H "Content-Type: application/json" \
-d '{"username":"admin","password":"<REDACTED>"}' | python3 -c "import sys,json; print(json.load(sys.stdin)['jwt'])")
# Create user as admin (Role:1), no password needed for OAuth users
curl -sk -X POST "https://portainer.kitestacks.com/api/users" \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-d '{"username":"user@example.com","role":1}'
Reset admin password (if locked out)
# Stop Portainer
docker stop portainer
# Reset password (shows new temp password)
docker run --rm -v portainer_data:/data portainer/helper-reset-password
# Restart
docker start portainer
BookStack
Setup (both monk and kscloud1)
Location:
- monk:
~/kitestacks-live/docker/bookstack/docker-compose.yml - kscloud1:
/opt/kitestacks/docker/bookstack/docker-compose.yml
Key environment variables:
- APP_URL=https://wiki.kitestacks.com
- DB_HOST=bookstack-db
- AUTH_METHOD=oidc
- OIDC_ISSUER=https://auth.kitestacks.com/application/o/bookstack/
- OIDC_ISSUER_DISCOVER=true
- OIDC_CLIENT_ID=bookstack
- OIDC_CLIENT_SECRET=<REDACTED>
- OIDC_USER_ATTRIBUTE=email
- APP_KEY=<REDACTED>
Generate APP_KEY
docker run --rm --entrypoint /bin/bash lscr.io/linuxserver/bookstack:latest appkey
OIDC Configuration
BookStack uses OIDC_ISSUER_DISCOVER=true to auto-discover all endpoints from Authentik.
The OIDC_ISSUER must match the per-app discovery URL base (not the global Authentik URL).
The Authentik bookstack provider must have issuer_mode='per_provider' so its discovery
document returns the correct per-app issuer URL. See Debug doc for full troubleshooting.
Fix cache permissions after artisan runs
Running php artisan as root creates root-owned cache dirs that block the app:
docker exec bookstack chown -R abc:users /config/www/framework/cache/
Clear Laravel config/cache
docker exec bookstack php /app/www/artisan config:clear
docker exec bookstack php /app/www/artisan cache:clear
kscloud1 Access
SSH
ssh -i ~/.ssh/id_ed25519_kscloud1 root@<KSCLOUD1_TAILSCALE_IP>
If SSH key is lost / not working
- Open Hetzner Cloud console:
console.hetzner.cloud→ your server → Console tab - Log in as
root(Linux user password) - Serve the key from monk over Tailscale:
# On monk — start temporary HTTP server cat ~/.ssh/id_ed25519_kscloud1.pub > ~/key.txt python3 -m http.server 7777 --directory ~/ - In Hetzner console, type:
curl http://<MONK_TAILSCALE_IP>:7777/key.txt > /root/.ssh/authorized_keys - Enable root SSH (if needed):
sed -i 's/^#*PermitRootLogin.*/PermitRootLogin prohibit-password/' /etc/ssh/sshd_config systemctl restart ssh
OSTicket SMTP
Config: smtp.gmail.com:587, STARTTLS
From: kitestacks.helpdesk@gmail.com (app password stored in DB)
To test email delivery: Admin Panel → Diagnostics → Send Test Email
Forgejo
Runs on monk at localhost:3006 (port 2222 for SSH git).
Generate API token for automation
docker exec -u git forgejo forgejo admin user generate-access-token \
--username kenpat --token-name "my-token" --raw \
--scopes "read:user,write:user,read:repository,write:repository"
Common Docker Operations
# View logs for a service
docker logs <container> --tail 50 -f
# Restart a service
cd ~/kitestacks-live/docker/<service> && docker compose restart
# Full stack restart
docker compose down && docker compose up -d
# Update a container image
docker compose pull && docker compose up -d
# Check all running containers
docker ps --format "table {{.Names}}\t{{.Status}}\t{{.Ports}}"