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>
478 lines
13 KiB
Markdown
478 lines
13 KiB
Markdown
# 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 <<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)
|