# 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**: ```bash # 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: ```bash 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: ```bash 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**: ```bash 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**: ```bash # 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 < docker-compose.yml < .env < 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: ```bash # 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**: ```bash mkdir -p ~/kitestacks-live/docker/forgejo cd ~/kitestacks-live/docker/forgejo KSCLOUD1_TAILSCALE=100.123.x.x # kscloud1's Tailscale IP cat > .env < 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/` 2. Create `.env` with secrets 3. Create `docker-compose.yml` 4. `docker compose up -d` 5. Verify with `docker ps` and `docker logs ` Detailed compose files for each service are in `~/kitestacks-homelab/apps//`. 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: ```bash docker exec bookstack chown -R abc:users /config/www/framework/cache/ docker compose restart bookstack ``` **kitestacks-metrics-api:** ```yaml 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: ```bash # 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: ```bash # 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: ```bash 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: ```bash 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: ```bash 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](07-troubleshooting.md)