Complete documentation suite for KiteStacks covering all 11 services across 2-host active-active architecture. Includes beginner track (with AI, 8 files) and advanced track (without AI, 7 files) with time estimates, real troubleshooting cases, and command-by-command explanations. Updates certifications roadmap to reflect July 7 2026 A+ Core 2 exam goal. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
13 KiB
Without AI — Part 6: Full Build
Track: Advanced (No AI)
Time for this section: 4–8 weeks
You now have the foundations: Linux, Bash, Python, Docker, and Networking. This section builds the entire KiteStacks homelab from scratch — command by command, with every command explained.
Before You Start
You need:
- Ubuntu 24.04 installed on your home PC (monk) and your VPS (kscloud1)
- A domain name with DNS managed by Cloudflare
- SSH key access to kscloud1
- Tailscale account and CLI installed on both machines
- Cloudflare account with a tunnel created (token saved)
Phase 1 — Prepare Both Machines
Run on both monk and kscloud1:
# Update the system
sudo apt update && sudo apt upgrade -y
# Install essential tools
sudo apt install -y curl git nano wget python3 python3-pip ufw
# Install Docker
sudo apt install -y ca-certificates curl
sudo install -m 0755 -d /etc/apt/keyrings
sudo curl -fsSL https://download.docker.com/linux/ubuntu/gpg \
-o /etc/apt/keyrings/docker.asc
sudo chmod a+r /etc/apt/keyrings/docker.asc
echo "deb [arch=$(dpkg --print-architecture) signed-by=/etc/apt/keyrings/docker.asc] \
https://download.docker.com/linux/ubuntu \
$(. /etc/os-release && echo "$VERSION_CODENAME") stable" | \
sudo tee /etc/apt/sources.list.d/docker.list > /dev/null
sudo apt update
sudo apt install -y docker-ce docker-ce-cli containerd.io docker-buildx-plugin docker-compose-plugin
# Enable and start Docker
sudo systemctl enable docker
sudo systemctl start docker
# Add your user to the docker group (avoids sudo for every docker command)
sudo usermod -aG docker $USER
# Log out and back in for this to take effect
# Create the shared Docker network
docker network create kitestacks
On kscloud1 specifically, set up the firewall:
sudo ufw default deny incoming
sudo ufw default allow outgoing
sudo ufw allow ssh
# Allow Docker bridge networks to reach host port 8000 (metrics API)
sudo ufw allow from 172.16.0.0/12 to any port 8000 proto tcp
sudo ufw --force enable
sudo ufw status verbose
Install Tailscale on both machines:
curl -fsSL https://tailscale.com/install.sh | sh
sudo tailscale up
# Follow the URL to authenticate
tailscale ip -4 # Note this IP — you will use it throughout the build
Phase 2 — Cloudflared (Tunnel Connector)
Run on monk:
mkdir -p ~/kitestacks-live/docker/cloudflared
cd ~/kitestacks-live/docker/cloudflared
cat > .env <<'EOF'
TUNNEL_TOKEN=your-tunnel-token-from-cloudflare
EOF
cat > docker-compose.yml <<'EOF'
services:
cloudflared:
image: cloudflare/cloudflared:latest
container_name: cloudflared
restart: unless-stopped
command: tunnel --no-autoupdate run
environment:
- TUNNEL_TOKEN=${TUNNEL_TOKEN:?set TUNNEL_TOKEN in .env}
networks:
- default
- kitestacks
networks:
kitestacks:
external: true
EOF
docker compose up -d
docker logs cloudflared # Confirm "Connection established"
Why ${TUNNEL_TOKEN:?set TUNNEL_TOKEN in .env}:
The :? syntax means: if the variable is unset or empty, exit with the given error message.
This prevents silently running cloudflared with no token (which would produce a confusing error).
Repeat on kscloud1 using the same token, same docker-compose.yml, at /opt/kitestacks/docker/cloudflared/.
Phase 3 — Shared Database Layer (on kscloud1)
The shared Postgres and Redis will run on kscloud1. Both monk's and kscloud1's Authentik will point to these. Forgejo will use the same Postgres (different database).
On kscloud1:
# Get kscloud1's Tailscale IP
TAILSCALE_IP=$(tailscale ip -4)
echo "Tailscale IP: $TAILSCALE_IP"
mkdir -p /opt/kitestacks/docker/authentik
cd /opt/kitestacks/docker/authentik
# Generate a strong Postgres password
PG_PASS=$(openssl rand -base64 32 | tr -d '/+=')
echo "Postgres password: $PG_PASS" # Save this
cat > .env <<EOF
PG_PASS=${PG_PASS}
EOF
cat > docker-compose.yml <<EOF
services:
authentik-postgres:
image: postgres:16-alpine
container_name: authentik-postgres
restart: unless-stopped
environment:
POSTGRES_PASSWORD: \${PG_PASS}
POSTGRES_USER: authentik
POSTGRES_DB: authentik
ports:
- "${TAILSCALE_IP}:5432:5432"
volumes:
- ./postgres:/var/lib/postgresql/data
networks:
- kitestacks
- authentik_default
authentik-redis:
image: redis:7-alpine
container_name: authentik-redis
restart: unless-stopped
ports:
- "${TAILSCALE_IP}:6379:6379"
networks:
- kitestacks
- authentik_default
networks:
kitestacks:
external: true
authentik_default:
name: authentik_default
EOF
docker compose up -d
docker ps # Confirm both containers are Up
# Verify Postgres is listening on Tailscale IP only (NOT 0.0.0.0)
docker exec authentik-postgres ss -tlnp | grep 5432
# Expected: LISTEN 0.0.0.0:5432 or 100.x.x.x:5432
Why the Tailscale IP binding matters:
"${TAILSCALE_IP}:5432:5432" tells Docker: bind host port 5432 only on the Tailscale
interface. If you used "5432:5432" (or "0.0.0.0:5432:5432"), Postgres would be
reachable from the public internet — a serious security risk. Only devices on your
Tailscale network can reach 100.x.x.x:5432.
Create the Forgejo database:
docker exec -e PGPASSWORD="${PG_PASS}" authentik-postgres \
psql -U authentik -c "CREATE USER forgejo WITH PASSWORD 'forgejo-password-here';"
docker exec -e PGPASSWORD="${PG_PASS}" authentik-postgres \
psql -U authentik -c "CREATE DATABASE forgejo OWNER forgejo;"
Phase 4 — Authentik (SSO)
On monk first:
mkdir -p ~/kitestacks-live/docker/authentik
cd ~/kitestacks-live/docker/authentik
# Get kscloud1's Tailscale IP
KSCLOUD1_TAILSCALE=100.123.x.x # Replace with your actual value
# Generate Authentik secret key (must be same on both hosts)
SECRET_KEY=$(openssl rand -base64 60 | tr -d '\n')
echo "Secret key: $SECRET_KEY" # Save this — both hosts need the SAME key
cat > .env <<EOF
PG_PASS=your-postgres-password-from-phase-3
AUTHENTIK_SECRET_KEY=${SECRET_KEY}
AUTHENTIK_POSTGRESQL__HOST=${KSCLOUD1_TAILSCALE}
AUTHENTIK_POSTGRESQL__USER=authentik
AUTHENTIK_POSTGRESQL__NAME=authentik
AUTHENTIK_POSTGRESQL__PASSWORD=your-postgres-password-from-phase-3
AUTHENTIK_REDIS__HOST=${KSCLOUD1_TAILSCALE}
AUTHENTIK_BOOTSTRAP_EMAIL=your@email.com
AUTHENTIK_BOOTSTRAP_PASSWORD=choose-strong-password
EOF
cat > docker-compose.yml <<'EOF'
services:
authentik:
image: ghcr.io/goauthentik/server:latest
container_name: authentik
restart: unless-stopped
command: server
env_file: .env
networks:
- kitestacks
authentik-worker:
image: ghcr.io/goauthentik/server:latest
container_name: authentik-worker
restart: unless-stopped
command: worker
env_file: .env
volumes:
- /var/run/docker.sock:/var/run/docker.sock
networks:
- kitestacks
networks:
kitestacks:
external: true
EOF
docker compose up -d
# Wait for Authentik to be healthy (takes ~2 minutes on first boot)
until [[ "$(docker inspect --format '{{.State.Health.Status}}' authentik)" == "healthy" ]]; do
echo "Waiting for Authentik... $(docker inspect --format '{{.State.Health.Status}}' authentik)"
sleep 10
done
echo "Authentik is healthy"
What happens on first boot: Authentik runs database migrations (creates all tables),
generates cryptographic keys, and starts the server. The worker process handles
background jobs (email, background flows). Both need the same .env file.
Why AUTHENTIK_REDIS__HOST and not just REDIS_HOST:
Authentik uses a config format where __ in environment variable names means "nested key".
AUTHENTIK_POSTGRESQL__HOST maps to authentik.postgresql.host in the config tree.
On kscloud1, create the same Authentik setup pointing to the local Postgres:
# On kscloud1, AUTHENTIK_POSTGRESQL__HOST should be authentik-postgres
# (via the Docker network), not the Tailscale IP
# kscloud1's Authentik is on the same Docker network as Postgres
Phase 5 — Forgejo
On monk:
mkdir -p ~/kitestacks-live/docker/forgejo
cd ~/kitestacks-live/docker/forgejo
KSCLOUD1_TAILSCALE=100.123.x.x # kscloud1's Tailscale IP
cat > .env <<EOF
FORGEJO__database__DB_TYPE=postgres
FORGEJO__database__HOST=${KSCLOUD1_TAILSCALE}:5432
FORGEJO__database__NAME=forgejo
FORGEJO__database__USER=forgejo
FORGEJO__database__PASSWD=forgejo-password-from-phase-3
FORGEJO__server__DOMAIN=gitforge.yourdomain.com
FORGEJO__server__ROOT_URL=https://gitforge.yourdomain.com
FORGEJO__server__SSH_DOMAIN=gitforge.yourdomain.com
EOF
cat > docker-compose.yml <<'EOF'
services:
forgejo:
image: codeberg.org/forgejo/forgejo:latest
container_name: forgejo
restart: unless-stopped
env_file: .env
volumes:
- ./data:/data
networks:
- kitestacks
networks:
kitestacks:
external: true
EOF
docker compose up -d
docker logs forgejo -f # Watch for errors
Visit gitforge.yourdomain.com. Complete the initial setup, then create your admin account.
On kscloud1: Same configuration. Both Forgejo instances point to the same Postgres forgejo database — so repos, users, and settings are identical on both.
Phase 6 — All Remaining Services
For each remaining service, the pattern is the same:
mkdir -p ~/kitestacks-live/docker/<service>- Create
.envwith secrets - Create
docker-compose.yml docker compose up -d- Verify with
docker psanddocker logs <container>
Detailed compose files for each service are in ~/kitestacks-homelab/apps/<service>/.
Use those as your reference — read each file before running it.
Key services and their main configuration points:
Karakeep: Provider ID is custom (not authentik) — OAuth redirect URI is
https://links.yourdomain.com/api/auth/callback/custom.
Kavita: OIDC must be configured via web UI (Settings → OIDC), not by file editing. Authority URL requires trailing slash.
BookStack: After first start, fix cache permissions:
docker exec bookstack chown -R abc:users /config/www/framework/cache/
docker compose restart bookstack
kitestacks-metrics-api:
services:
kitestacks-metrics-api:
image: your-metrics-api-image # Build from apps/kitestacks-metrics-api/
container_name: kitestacks-metrics-api
restart: unless-stopped
network_mode: host # Must be host — not kitestacks network
pid: host # Must be host — reads /proc for real stats
environment:
- FORGEJO_API_BASE=https://gitforge.yourdomain.com
- FORGEJO_TOKEN=your-forgejo-api-token
Note: network_mode: host and networks: cannot coexist. The metrics API is reachable
at host.docker.internal:8000 from other containers.
Phase 7 — SSO Configuration
For each service, in Authentik admin panel (auth.yourdomain.com/if/admin/):
-
Applications → Providers → Create → OAuth2/OpenID Provider
- Client type: Confidential
- Redirect URIs: service-specific (see SSO guide)
- Signing key: authentik Self-signed Certificate
- Scopes: openid, email, profile
-
Applications → Applications → Create
- Provider: the one you just created
- Launch URL: the service's public URL
-
(For sensitive services) Policy Binding → restrict to
homelab-admingroup
OAuth2 code TTL — increase to prevent invalid_grant during monk reconnect:
# Connect to shared Postgres from kscloud1
docker exec -it authentik-postgres psql -U authentik -d authentik
-- Increase code lifetime for all providers to 10 minutes
UPDATE authentik_providers_oauth2_oauth2provider
SET access_code_validity = '00:10:00';
-- Restart both Authentik instances after this
\q
Phase 8 — Push Everything to kscloud1
With monk as the source, push configurations to kscloud1:
# For each service, copy the docker-compose.yml and .env (with paths adjusted)
# The standard pattern:
for service in forgejo karakeep kavita grafana uptime-kuma bookstack osticket portainer; do
ssh -i ~/.ssh/id_ed25519_kscloud1 kenpat@100.123.x.x \
"mkdir -p /opt/kitestacks/docker/$service"
scp -i ~/.ssh/id_ed25519_kscloud1 \
~/kitestacks-live/docker/$service/docker-compose.yml \
~/kitestacks-live/docker/$service/.env \
kenpat@100.123.x.x:/opt/kitestacks/docker/$service/
done
Then on kscloud1, start each service:
for service in forgejo karakeep kavita grafana uptime-kuma bookstack osticket portainer; do
cd /opt/kitestacks/docker/$service
docker compose up -d
done
Verify all 11 services return the expected status:
for url in www auth gitforge ai links kavita grafana status wiki tasks portainer; do
code=$(curl -s -o /dev/null -w "%{http_code}" "https://${url}.yourdomain.com" --max-time 5)
echo "${url}.yourdomain.com: ${code}"
done
All should return 200 or 302 (redirect to login).
Committing Everything to Forgejo
Once your homelab is working, commit all configurations:
cd ~/kitestacks-live
git init
git remote add origin https://gitforge.yourdomain.com/kenpat/kitestacks-live.git
# Add a .gitignore BEFORE adding files — never commit secrets
cat > .gitignore <<'EOF'
**/.env
**/data/
**/postgres/
**/config/
**/*.db
**/*.db-shm
**/*.db-wal
EOF
git add docker-compose.yml docker/*/docker-compose.yml
git commit -m "initial: all service compose files"
git push origin main
Your .env files (which contain passwords and tokens) must NEVER be committed.
The .gitignore above prevents this.
Next: Part 7 — Troubleshooting