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

478 lines
13 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# 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**:
```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 <<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:
```bash
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:
```bash
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:
```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 <<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:
```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)