kitestacks-homelab/homelab-mastery/build-guide/without-ai/06-full-build.md
kenpat 1e8319ee75 docs: comprehensive homelab-mastery rewrite with full build guides
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>
2026-06-19 01:08:43 -05:00

13 KiB
Raw Blame History

Without AI — Part 6: Full Build

Track: Advanced (No AI)
Time for this section: 48 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:

  1. mkdir -p ~/kitestacks-live/docker/<service>
  2. Create .env with secrets
  3. Create docker-compose.yml
  4. docker compose up -d
  5. Verify with docker ps and docker 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/):

  1. 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
  2. Applications → Applications → Create

    • Provider: the one you just created
    • Launch URL: the service's public URL
  3. (For sensitive services) Policy Binding → restrict to homelab-admin group

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