From 716baafbc16807a62f2776be056bb969afeb9f32 Mon Sep 17 00:00:00 2001 From: 1jamesthompson1 <1jamesthompson1@gmail.com> Date: Mon, 23 Mar 2026 16:06:42 +1300 Subject: [PATCH] AI given structure --- .env.example | 17 + .gitignore | 23 + Makefile | 43 ++ README.md | 79 +++ backup.sh | 24 + devbox/.env.example | 6 + devbox/docker-compose.yml | 13 + docs/index.md | 34 ++ docs/recovery.md | 66 +++ docs/services/devbox.md | 42 ++ docs/services/gitea.md | 58 +++ docs/services/jellyfin.md | 55 ++ docs/services/n8n.md | 9 + docs/services/nextcloud.md | 56 +++ docs/services/obsidian.md | 42 ++ docs/services/qbittorrent.md | 153 ++++++ docs/services/supersync.md | 9 + docs/services/traefik.md | 67 +++ docs/services/whoami.md | 28 ++ gitea/.env.example | 3 + gitea/docker-compose.yml | 35 ++ jellyfin/.env.example | 4 + jellyfin/docker-compose.yml | 25 + mkdocs.yml | 18 + nextcloud/.env.example | 4 + nextcloud/docker-compose.yml | 27 + obsidian-livesync/.env.example | 6 + obsidian-livesync/docker-compose.yml | 15 + qbittorrent/.env.example | 8 + qbittorrent/docker-compose.yml | 61 +++ .../__pycache__/generate_docs.cpython-313.pyc | Bin 0 -> 23068 bytes scripts/generate_docs.py | 473 ++++++++++++++++++ traefik/.env.example | 6 + traefik/docker-compose.yml | 45 ++ whoami/docker-compose.yml | 16 + 35 files changed, 1570 insertions(+) create mode 100644 .env.example create mode 100644 .gitignore create mode 100644 Makefile create mode 100644 README.md create mode 100755 backup.sh create mode 100644 devbox/.env.example create mode 100644 devbox/docker-compose.yml create mode 100644 docs/index.md create mode 100644 docs/recovery.md create mode 100644 docs/services/devbox.md create mode 100644 docs/services/gitea.md create mode 100644 docs/services/jellyfin.md create mode 100644 docs/services/n8n.md create mode 100644 docs/services/nextcloud.md create mode 100644 docs/services/obsidian.md create mode 100644 docs/services/qbittorrent.md create mode 100644 docs/services/supersync.md create mode 100644 docs/services/traefik.md create mode 100644 docs/services/whoami.md create mode 100644 gitea/.env.example create mode 100644 gitea/docker-compose.yml create mode 100644 jellyfin/.env.example create mode 100644 jellyfin/docker-compose.yml create mode 100644 mkdocs.yml create mode 100644 nextcloud/.env.example create mode 100644 nextcloud/docker-compose.yml create mode 100644 obsidian-livesync/.env.example create mode 100644 obsidian-livesync/docker-compose.yml create mode 100644 qbittorrent/.env.example create mode 100644 qbittorrent/docker-compose.yml create mode 100644 scripts/__pycache__/generate_docs.cpython-313.pyc create mode 100755 scripts/generate_docs.py create mode 100644 traefik/.env.example create mode 100644 traefik/docker-compose.yml create mode 100644 whoami/docker-compose.yml diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..a5e5d61 --- /dev/null +++ b/.env.example @@ -0,0 +1,17 @@ +# homelab-infra/.env +# Global environment variables shared across all services +# Copy to .env and fill in real values. NEVER commit .env. + +# Storage +STORAGE_PATH=/mnt/storage/docker-data +MEDIA_PATH=/mnt/storage + +# User +USER_UID=1000 +USER_GID=1000 + +# Domain +DOMAIN=sjhl.nz + +# Timezone +TZ=Pacific/Auckland diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..869f935 --- /dev/null +++ b/.gitignore @@ -0,0 +1,23 @@ +# Secrets - never commit +.env +*.env +!.env.example + +# Docker runtime +**/acme.json +**/logs/ +**/*.log + +# OS +.DS_Store +Thumbs.db + +# MkDocs +site/ + +# Editor +.vscode/ +.idea/ +*.swp +*.swo +*~ diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..07a887e --- /dev/null +++ b/Makefile @@ -0,0 +1,43 @@ +SERVICES=traefik whoami gitea nextcloud qbittorrent jellyfin devbox obsidian n8n supersync + +.PHONY: up down restart backup docs generate-docs logs status + +up: + @for svc in $(SERVICES); do \ + if [ -f "$$svc/docker-compose.yml" ]; then \ + echo "Starting $$svc..."; \ + (cd $$svc && docker compose up -d); \ + fi; \ + done + +down: + @for svc in $(SERVICES); do \ + if [ -f "$$svc/docker-compose.yml" ]; then \ + echo "Stopping $$svc..."; \ + (cd $$svc && docker compose down); \ + fi; \ + done + +restart: down up + +logs: + @echo "=== Traefik ===" && (cd traefik && docker compose logs --tail=10) + @echo "=== Gitea ===" && (cd gitea && docker compose logs --tail=10) + @echo "=== Nextcloud ===" && (cd nextcloud && docker compose logs --tail=10) + +status: + @for svc in $(SERVICES); do \ + if [ -f "$$svc/docker-compose.yml" ]; then \ + echo "--- $$svc ---"; \ + (cd $$svc && docker compose ps --format "table {{.Name}}\t{{.Status}}\t{{.Ports}}"); \ + fi; \ + done + +backup: + ./backup.sh + +generate-docs: + python3 scripts/generate_docs.py + +docs: generate-docs + mkdocs build diff --git a/README.md b/README.md new file mode 100644 index 0000000..c7f2325 --- /dev/null +++ b/README.md @@ -0,0 +1,79 @@ +# homelab-infra + +GitOps-managed infrastructure for homelab services. + +## Quick Start + +```bash +git clone git@gitea.sjhl.nz:james/homelab-infra.git +cd homelab-infra + +# Set up environment +cp .env.example .env +# Edit .env with real values + +# Set up service secrets +for svc in traefik gitea nextcloud qbittorrent jellyfin devbox obsidian; do + [ -f "$svc/.env.example" ] && cp "$svc/.env.example" "$svc/.env" + # Edit each .env with real secrets +done + +# Create required Docker networks +docker network create web + +# Start everything +make up +``` + +## Services + +| Service | Description | URL | +|---------|-------------|-----| +| traefik | Reverse proxy, TLS termination | `dashboard.sjhl.nz` | +| gitea | Self-hosted Git | `gitea.sjhl.nz` | +| nextcloud | Cloud storage (AIO) | `nextcloud.sjhl.nz` | +| qbittorrent | Torrent client (VPN) | Internal only | +| jellyfin | Media server | `jellyfin.sjhl.nz` | +| devbox | Dev container | SSH:46573 | +| obsidian | CouchDB for Obsidian LiveSync | Internal | +| n8n | Workflow automation (planned) | - | +| supersync | File sync (planned) | - | + +## Commands + +```bash +make up # Start all services +make down # Stop all services +make restart # Restart all services +make status # Show status of all services +make logs # Show recent logs +make backup # Run Restic backup +make docs # Build MkDocs site +``` + +## Directory Structure + +``` +homelab-infra/ +├── .env.example # Global env template +├── Makefile # Service management +├── backup.sh # Restic backup script +├── traefik/ # Reverse proxy +├── gitea/ # Git hosting +├── nextcloud/ # Cloud storage (AIO) +├── qbittorrent/ # Torrent + VPN stack +├── jellyfin/ # Media server +├── devbox/ # Development container +├── obsidian/ # CouchDB for LiveSync +├── n8n/ # Workflow automation +├── supersync/ # File sync +└── docs/ # MkDocs documentation +``` + +## Data Locations + +- Configs (in Git): this repo +- Secrets (not in Git): per-service `.env` files +- Persistent data: `/mnt/storage/docker-data/` +- Media: `/mnt/storage` +- Backups: `/mnt/storage/backups` diff --git a/backup.sh b/backup.sh new file mode 100755 index 0000000..947f6c3 --- /dev/null +++ b/backup.sh @@ -0,0 +1,24 @@ +#!/bin/bash +# Backup all persistent service data using Restic +set -euo pipefail + +# Load environment +source .env + +export RESTIC_REPOSITORY=/mnt/storage/backups +export RESTIC_PASSWORD_FILE=/root/.restic_pass + +SERVICES=(gitea nextcloud qbittorrent jellyfin devbox obsidian traefik) + +for svc in "${SERVICES[@]}"; do + target="${STORAGE_PATH}/${svc}" + if [ -d "$target" ]; then + echo "Backing up $svc..." + restic backup "$target" --tag "$svc" + else + echo "Skipping $svc - directory $target not found" + fi +done + +echo "Backup complete." +restic snapshots --latest 5 diff --git a/devbox/.env.example b/devbox/.env.example new file mode 100644 index 0000000..b3a0b6e --- /dev/null +++ b/devbox/.env.example @@ -0,0 +1,6 @@ +# devbox/.env +# Copy to .env and fill in real values. NEVER commit .env. + +DEVBOX_SSH_PORT=46573 +DEVBOX_MEM_LIMIT=10g +DEVBOX_SWAP_LIMIT=20g diff --git a/devbox/docker-compose.yml b/devbox/docker-compose.yml new file mode 100644 index 0000000..365390a --- /dev/null +++ b/devbox/docker-compose.yml @@ -0,0 +1,13 @@ +services: + devcontainer: + image: devbox-devcontainer + container_name: devcontainer + restart: unless-stopped + ports: + - "${DEVBOX_SSH_PORT:-46573}:2222" + volumes: + - ${STORAGE_PATH}/devbox:/home/devuser + env_file: + - ../.env + mem_limit: ${DEVBOX_MEM_LIMIT:-10g} + memswap_limit: ${DEVBOX_SWAP_LIMIT:-20g} diff --git a/docs/index.md b/docs/index.md new file mode 100644 index 0000000..fc0b0eb --- /dev/null +++ b/docs/index.md @@ -0,0 +1,34 @@ +# Homelab Documentation + +> Auto-generated from `docker-compose.yml` files + +## Data Classification + +| Type | Example | Git Repo? | Backup? | Location | +|------|---------|-----------|---------|----------| +| **Source Configs** | `docker-compose.yml`, `.env.example`, `Makefile` | Yes | No | `/srv/homelab-infra` | +| **Service Secrets** | `.env` (DB passwords, API keys) | No | Yes | `/srv/homelab-infra//.env` | +| **Runtime Configs** | `acme.json`, service configs | No | Yes | `/mnt/storage/docker-data/` | +| **Persistent Data** | DB data, uploads, media | No | Yes | `/mnt/storage/docker-data/` | + +## Services + +| Service | Image | Status | +|---------|-------|--------| +| [devbox](services/devbox.md) | `devbox-devcontainer` | active | +| [gitea](services/gitea.md) | `gitea/gitea:1.24.3` | active | +| [jellyfin](services/jellyfin.md) | `jellyfin/jellyfin:latest` | active | +| [nextcloud](services/nextcloud.md) | `ghcr.io/nextcloud-releases/all-in-one:latest` | active | +| [obsidian](services/obsidian.md) | `couchdb:latest` | active | +| [qbittorrent](services/qbittorrent.md) | `qmcgaw/gluetun`, `lscr.io/linuxserver/qbittorrent:latest`, `linuxserver/jackett:latest` | active | +| [traefik](services/traefik.md) | `traefik:v3.6` | active | +| [whoami](services/whoami.md) | `traefik/whoami` | active | + +## Quick Start + +```bash +cp .env.example .env +for svc in */; do [ -f "$svc/.env.example" ] && cp "$svc/.env.example" "$svc/.env"; done +docker network create web +make up +``` diff --git a/docs/recovery.md b/docs/recovery.md new file mode 100644 index 0000000..c953cd1 --- /dev/null +++ b/docs/recovery.md @@ -0,0 +1,66 @@ +# Recovery Reference + +This document was generated from `docker inspect` dumps taken after the accidental deletion of `/home/james`. It preserves the last known-good configuration state for rebuilding services. + +## Container Summary + +### traefik (Reverse Proxy) +- **Image:** `traefik:v3.6` +- **Config path (old):** `/home/james/apps/traefik/` +- **Volumes:** certs, dynamic config, docker.sock (ro) +- **Networks:** `web` (external) +- **Ports:** 80, 443, 8082->8080 +- **Let's Encrypt:** HTTP challenge, email `letsencrypt.debug772@passmail.net` + +### gitea +- **Image:** `gitea/gitea:1.24.3` +- **Data path (old):** `/mnt/storage/apps/gitea` +- **DB:** SQLite3 +- **SSH:** port 222 +- **URL:** `https://gitea.sjhl.nz/` + +### nextcloud (AIO) +- **Image:** `ghcr.io/nextcloud-releases/all-in-one:latest` +- **Data path (old):** `/mnt/storage/apps/nextcloud` +- **Components:** mastercontainer, apache, nextcloud, database (PostgreSQL 17), redis, collabora, imaginary, whiteboard, notify-push +- **URL:** `https://nextcloud.sjhl.nz` +- **AIO mgmt:** port 8081 +- **Apache port:** 11000 + +### qbittorrent + VPN +- **VPN:** ProtonVPN WireGuard, New Zealand, port forwarding +- **Data path (old):** `/home/james/apps/qbittorrent/` +- **Downloads:** `/mnt/storage/torrents` +- **Components:** gluetun, qbittorrent, jackett +- **Ports:** 8080 (qBittorrent), 9117 (Jackett) + +### jellyfin +- **Image:** `jellyfin/jellyfin:10.10.7` +- **Config path (old):** `/home/james/apps/jellyfin-config` +- **Media:** `/mnt/storage` +- **Port:** 8096 +- **Network:** default bridge (NOT on `web` network) +- **Note:** Was not behind Traefik in old setup + +### devbox +- **Image:** `devbox-devcontainer` (local build) +- **Data path (old):** `/mnt/storage/apps/devbox` +- **Port:** 46573->2222 (SSH) +- **Memory limit:** 10GB, swap 20GB + +### obsidian-livesync +- **Image:** `couchdb:latest` +- **Status:** Exited (127) at time of backup +- **Note:** No inspect data captured, container was stopped + +## Recovery Path Mapping + +| Old Path | New Path | +|----------|----------| +| `/home/james/apps/traefik/` | `/mnt/storage/docker-data/traefik/` | +| `/home/james/apps/qbittorrent/` | `/mnt/storage/docker-data/qbittorrent/` | +| `/home/james/apps/jellyfin-config` | `/mnt/storage/docker-data/jellyfin/config` | +| `/mnt/storage/apps/gitea` | `/mnt/storage/docker-data/gitea` | +| `/mnt/storage/apps/nextcloud` | `/mnt/storage/docker-data/nextcloud` | +| `/mnt/storage/apps/devbox` | `/mnt/storage/docker-data/devbox` | +| `/mnt/storage/torrents` | `/mnt/storage/torrents` (unchanged) | diff --git a/docs/services/devbox.md b/docs/services/devbox.md new file mode 100644 index 0000000..a84a4d6 --- /dev/null +++ b/docs/services/devbox.md @@ -0,0 +1,42 @@ +# devbox + +> Auto-generated from `docker-compose.yml` + +| Field | Value | +|-------|-------| +| **Image** | `devbox-devcontainer` | +| **Container** | `devcontainer` | +| **Restart** | `unless-stopped` | + +### Environment + +**Env files:** `../.env` + +### Secrets (from `.env.example`) + +These variables must be set in the service's `.env` file: + +| Variable | Default | +|----------|---------| +| `DEVBOX_SSH_PORT` | `46573` | +| `DEVBOX_MEM_LIMIT` | `10g` | +| `DEVBOX_SWAP_LIMIT` | `20g` | + +### Ports + +| Host | Container | Protocol | +|------|-----------|----------| +| `${DEVBOX_SSH_PORT:-46573}` | `2222` | tcp | + +### Volumes + +| Host Path | Container Path | Mode | +|-----------|----------------|------| +| `${STORAGE_PATH}/devbox` | `/home/devuser` | rw | + +### Resources + +- Memory limit: `${DEVBOX_MEM_LIMIT:-10g}` +- Memory+Swap limit: `${DEVBOX_SWAP_LIMIT:-20g}` + +--- diff --git a/docs/services/gitea.md b/docs/services/gitea.md new file mode 100644 index 0000000..f66a31f --- /dev/null +++ b/docs/services/gitea.md @@ -0,0 +1,58 @@ +# gitea + +> Auto-generated from `docker-compose.yml` + +| Field | Value | +|-------|-------| +| **Image** | `gitea/gitea:1.24.3` | +| **Container** | `gitea` | +| **Restart** | `always` | + +### Environment + +**Env files:** `../.env`, `.env` + +| Variable | Value | +|----------|-------| +| `USER_UID` | `${USER_UID}` | +| `USER_GID` | `${USER_GID}` | +| `GITEA__database__DB_TYPE` | `sqlite3` | +| `GITEA__server__ROOT_URL` | `https://gitea.${DOMAIN}/` | +| `USER` | `git` | +| `GITEA_CUSTOM` | `/data/gitea` | + +### Ports + +| Host | Container | Protocol | +|------|-----------|----------| +| `222` | `22` | tcp | + +### Volumes + +| Host Path | Container Path | Mode | +|-----------|----------------|------| +| `${STORAGE_PATH}/gitea` | `/data` | rw | +| `/etc/timezone` | `/etc/timezone` | ro | +| `/etc/localtime` | `/etc/localtime` | ro | + +### Networks + +- `gitea` (internal) +- `web` (internal) + +### Labels + +| Key | Value | +|-----|-------| +| `traefik.enable` | `true` | +| `traefik.http.routers.gitea.entrypoints` | `websecure` | +| `traefik.http.routers.gitea.rule` | `Host(`gitea.${DOMAIN}`)` | +| `traefik.http.routers.gitea.tls.certresolver` | `letsencrypt` | +| `traefik.http.services.gitea.loadbalancer.server.port` | `3000` | + +### Networks (compose-level) + +- `gitea` (internal) +- `web` (external) + +--- diff --git a/docs/services/jellyfin.md b/docs/services/jellyfin.md new file mode 100644 index 0000000..ad3b506 --- /dev/null +++ b/docs/services/jellyfin.md @@ -0,0 +1,55 @@ +# jellyfin + +> Auto-generated from `docker-compose.yml` + +| Field | Value | +|-------|-------| +| **Image** | `jellyfin/jellyfin:latest` | +| **Container** | `jellyfin` | +| **Restart** | `unless-stopped` | + +### Environment + +**Env files:** `../.env` + +### Secrets (from `.env.example`) + +These variables must be set in the service's `.env` file: + +| Variable | Default | +|----------|---------| +| `JELLYFIN_PORT` | `8096` | + +### Ports + +| Host | Container | Protocol | +|------|-----------|----------| +| `${JELLYFIN_PORT:-8096}` | `8096` | tcp | + +### Volumes + +| Host Path | Container Path | Mode | +|-----------|----------------|------| +| `${STORAGE_PATH}/jellyfin/config` | `/config` | rw | +| `${STORAGE_PATH}/jellyfin/cache` | `/cache` | rw | +| `${MEDIA_PATH}` | `/media` | rw | + +### Networks + +- `web` (internal) + +### Labels + +| Key | Value | +|-----|-------| +| `traefik.enable` | `true` | +| `traefik.http.routers.jellyfin.entrypoints` | `websecure` | +| `traefik.http.routers.jellyfin.rule` | `Host(`jellyfin.${DOMAIN}`)` | +| `traefik.http.routers.jellyfin.tls.certresolver` | `letsencrypt` | +| `traefik.http.services.jellyfin.loadbalancer.server.port` | `8096` | + +### Networks (compose-level) + +- `web` (external) + +--- diff --git a/docs/services/n8n.md b/docs/services/n8n.md new file mode 100644 index 0000000..162fe62 --- /dev/null +++ b/docs/services/n8n.md @@ -0,0 +1,9 @@ +# N8N + +Workflow automation platform. **Planned - not yet deployed.** + +## Planned Configuration + +- **URL:** `https://n8n.sjhl.nz` +- **Port:** 5678 +- **Data:** `/mnt/storage/docker-data/n8n` diff --git a/docs/services/nextcloud.md b/docs/services/nextcloud.md new file mode 100644 index 0000000..51d8fb9 --- /dev/null +++ b/docs/services/nextcloud.md @@ -0,0 +1,56 @@ +# nextcloud + +> Auto-generated from `docker-compose.yml` + +| Field | Value | +|-------|-------| +| **Image** | `ghcr.io/nextcloud-releases/all-in-one:latest` | +| **Container** | `nextcloud-aio-mastercontainer` | +| **Restart** | `always` | + +### Environment + +**Env files:** `../.env` + +| Variable | Value | +|----------|-------| +| `APACHE_PORT` | `11000` | +| `NEXTCLOUD_DATADIR` | `${STORAGE_PATH}/nextcloud` | +| `APACHE_ADDITIONAL_NETWORK` | `web` | + +### Secrets (from `.env.example`) + +These variables must be set in the service's `.env` file: + +| Variable | Default | +|----------|---------| +| `AIO_PORT` | `8081` | + +### Ports + +| Host | Container | Protocol | +|------|-----------|----------| +| `${AIO_PORT:-8081}` | `8080` | tcp | + +### Volumes + +| Host Path | Container Path | Mode | +|-----------|----------------|------| +| `nextcloud_aio_mastercontainer` | `/mnt/docker-aio-config` | rw | +| `/var/run/docker.sock` | `/var/run/docker.sock` | ro | + +### Networks + +- `nextcloud-aio` (internal) +- `web` (internal) + +### Named Volumes (compose-level) + +- `nextcloud_aio_mastercontainer` (managed by compose) + +### Networks (compose-level) + +- `nextcloud-aio` (internal) +- `web` (external) + +--- diff --git a/docs/services/obsidian.md b/docs/services/obsidian.md new file mode 100644 index 0000000..2f58329 --- /dev/null +++ b/docs/services/obsidian.md @@ -0,0 +1,42 @@ +# obsidian + +> Auto-generated from `docker-compose.yml` + +| Field | Value | +|-------|-------| +| **Image** | `couchdb:latest` | +| **Container** | `obsidian-livesync` | +| **Restart** | `unless-stopped` | + +### Environment + +**Env files:** `../.env`, `.env` + +| Variable | Value | +|----------|-------| +| `COUCHDB_USER` | `${COUCHDB_USER:-admin}` | +| `COUCHDB_PASSWORD` | `${COUCHDB_PASSWORD}` | + +### Secrets (from `.env.example`) + +These variables must be set in the service's `.env` file: + +| Variable | Default | +|----------|---------| +| `COUCHDB_USER` | `admin` | +| `COUCHDB_PASSWORD` | `REPLACE_WITH_STRONG_PASSWORD` | +| `COUCHDB_PORT` | `5984` | + +### Ports + +| Host | Container | Protocol | +|------|-----------|----------| +| `${COUCHDB_PORT:-5984}` | `5984` | tcp | + +### Volumes + +| Host Path | Container Path | Mode | +|-----------|----------------|------| +| `${STORAGE_PATH}/obsidian` | `/opt/couchdb/data` | rw | + +--- diff --git a/docs/services/qbittorrent.md b/docs/services/qbittorrent.md new file mode 100644 index 0000000..9534bd0 --- /dev/null +++ b/docs/services/qbittorrent.md @@ -0,0 +1,153 @@ +# qbittorrent + +> Auto-generated from `docker-compose.yml` + +## `gluetun` + +| Field | Value | +|-------|-------| +| **Image** | `qmcgaw/gluetun` | +| **Container** | `qbittorrent_gluetun` | +| **Restart** | `unless-stopped` | + +### Environment + +**Env files:** `../.env`, `.env` + +| Variable | Value | +|----------|-------| +| `VPN_SERVICE_PROVIDER` | `protonvpn` | +| `VPN_TYPE` | `wireguard` | +| `VPN_PORT_FORWARDING` | `on` | +| `TZ` | `${TZ}` | +| `SERVER_COUNTRIES` | `New Zealand` | +| `PORT_FORWARD_ONLY` | `on` | +| `VPN_PORT_FORWARDING_UP_COMMAND` | `/bin/sh -c 'wget -O- --post-data "json={\"listen_port\":{{PORTS}}}" http://localhost:8080/api/v2/app/setPreferences 2>/dev/null || true'` | + +### Secrets (from `.env.example`) + +These variables must be set in the service's `.env` file: + +| Variable | Default | +|----------|---------| +| `WIREGUARD_PRIVATE_KEY` | `REPLACE_WITH_YOUR_WIREGUARD_PRIVATE_KEY` | +| `QBITTORRENT_PORT` | `8080` | +| `JACKETT_PORT` | `9117` | + +### Ports + +| Host | Container | Protocol | +|------|-----------|----------| +| `${QBITTORRENT_PORT:-8080}` | `8080` | tcp | +| `${JACKETT_PORT:-9117}` | `9117` | tcp | + +### Volumes + +| Host Path | Container Path | Mode | +|-----------|----------------|------| +| `${STORAGE_PATH}/qbittorrent/gluetun` | `/gluetun` | rw | +| `${STORAGE_PATH}/qbittorrent/wireguard` | `/gluetun/wireguard` | rw | + +### Networks + +- `qbittorrent` (internal) + +### Capabilities + +`NET_ADMIN` + +### Devices + +- `/dev/net/tun:/dev/net/tun` + +### Networks (compose-level) + +- `qbittorrent` (internal) + +--- + +## `qbittorrent` + +| Field | Value | +|-------|-------| +| **Image** | `lscr.io/linuxserver/qbittorrent:latest` | +| **Container** | `qbittorrent` | +| **Restart** | `unless-stopped` | + +### Environment + +| Variable | Value | +|----------|-------| +| `TZ` | `${TZ}` | +| `WEBUI_PORT` | `8080` | +| `PUID` | `${USER_UID}` | +| `PGID` | `${USER_GID}` | + +### Secrets (from `.env.example`) + +These variables must be set in the service's `.env` file: + +| Variable | Default | +|----------|---------| +| `WIREGUARD_PRIVATE_KEY` | `REPLACE_WITH_YOUR_WIREGUARD_PRIVATE_KEY` | +| `QBITTORRENT_PORT` | `8080` | +| `JACKETT_PORT` | `9117` | + +### Volumes + +| Host Path | Container Path | Mode | +|-----------|----------------|------| +| `${STORAGE_PATH}/qbittorrent/config` | `/config` | rw | +| `${MEDIA_PATH}/torrents` | `/downloads` | rw | + +### Dependencies + +- `gluetun` + +### Networks (compose-level) + +- `qbittorrent` (internal) + +--- + +## `jackett` + +| Field | Value | +|-------|-------| +| **Image** | `linuxserver/jackett:latest` | +| **Container** | `jackett` | +| **Restart** | `unless-stopped` | + +### Environment + +| Variable | Value | +|----------|-------| +| `TZ` | `${TZ}` | +| `PUID` | `${USER_UID}` | +| `PGID` | `${USER_GID}` | + +### Secrets (from `.env.example`) + +These variables must be set in the service's `.env` file: + +| Variable | Default | +|----------|---------| +| `WIREGUARD_PRIVATE_KEY` | `REPLACE_WITH_YOUR_WIREGUARD_PRIVATE_KEY` | +| `QBITTORRENT_PORT` | `8080` | +| `JACKETT_PORT` | `9117` | + +### Volumes + +| Host Path | Container Path | Mode | +|-----------|----------------|------| +| `${STORAGE_PATH}/qbittorrent/config/jackett` | `/config` | rw | + +### Dependencies + +- `gluetun` + +### Networks (compose-level) + +- `qbittorrent` (internal) + +--- diff --git a/docs/services/supersync.md b/docs/services/supersync.md new file mode 100644 index 0000000..24ae5bc --- /dev/null +++ b/docs/services/supersync.md @@ -0,0 +1,9 @@ +# Supersync + +File synchronization service. **Planned - not yet deployed.** + +## Planned Configuration + +- **URL:** `https://supersync.sjhl.nz` +- **Port:** 8443 +- **Data:** `/mnt/storage/docker-data/supersync` diff --git a/docs/services/traefik.md b/docs/services/traefik.md new file mode 100644 index 0000000..4ca12ca --- /dev/null +++ b/docs/services/traefik.md @@ -0,0 +1,67 @@ +# traefik + +> Auto-generated from `docker-compose.yml` + +| Field | Value | +|-------|-------| +| **Image** | `traefik:v3.6` | +| **Container** | `traefik` | +| **Restart** | `unless-stopped` | + +### Command + +``` +--entrypoints.web.address=:80 --entrypoints.web.http.redirections.entrypoint.to=websecure --entrypoints.web.http.redirections.entrypoint.scheme=https --entrypoints.websecure.address=:443 --providers.docker=true --providers.docker.exposedbydefault=false --providers.docker.network=web --certificatesresolvers.letsencrypt.acme.httpchallenge=true --certificatesresolvers.letsencrypt.acme.httpchallenge.entrypoint=web --certificatesresolvers.letsencrypt.acme.email=${ACME_EMAIL} --certificatesresolvers.letsencrypt.acme.storage=/certs/acme.json --metrics.prometheus=true --accesslog=true --providers.file.directory=/dynamic --providers.file.watch=true +``` + +### Secrets (from `.env.example`) + +These variables must be set in the service's `.env` file: + +| Variable | Default | +|----------|---------| +| `TRAEFIK_DASHBOARD_PORT` | `8082` | +| `ACME_EMAIL` | `letsencrypt@example.com` | +| `DASHBOARD_BASIC_AUTH` | `admin:$$apr1$$changeme$$REPLACE_WITH_HTPASSWD_HASH` | + +### Ports + +| Host | Container | Protocol | +|------|-----------|----------| +| `80` | `80` | tcp | +| `443` | `443` | tcp | +| `${TRAEFIK_DASHBOARD_PORT:-8082}` | `8080` | tcp | + +### Volumes + +| Host Path | Container Path | Mode | +|-----------|----------------|------| +| `/var/run/docker.sock` | `/var/run/docker.sock` | ro | +| `${STORAGE_PATH}/traefik/certs` | `/certs` | rw | +| `${STORAGE_PATH}/traefik/dynamic` | `/dynamic` | ro | + +### Networks + +- `web` (internal) + +### Security Options + +- `no-new-privileges:true` + +### Labels + +| Key | Value | +|-----|-------| +| `traefik.enable` | `true` | +| `traefik.http.middlewares.dashboard-auth.basicauth.users` | `${DASHBOARD_BASIC_AUTH}` | +| `traefik.http.routers.dashboard.entrypoints` | `websecure` | +| `traefik.http.routers.dashboard.middlewares` | `dashboard-auth@docker` | +| `traefik.http.routers.dashboard.rule` | `Host(`dashboard.${DOMAIN}`)` | +| `traefik.http.routers.dashboard.service` | `api@internal` | +| `traefik.http.routers.dashboard.tls` | `true` | + +### Networks (compose-level) + +- `web` (external) + +--- diff --git a/docs/services/whoami.md b/docs/services/whoami.md new file mode 100644 index 0000000..adeeccc --- /dev/null +++ b/docs/services/whoami.md @@ -0,0 +1,28 @@ +# whoami + +> Auto-generated from `docker-compose.yml` + +| Field | Value | +|-------|-------| +| **Image** | `traefik/whoami` | +| **Container** | `whoami` | +| **Restart** | `unless-stopped` | + +### Networks + +- `web` (internal) + +### Labels + +| Key | Value | +|-----|-------| +| `traefik.enable` | `true` | +| `traefik.http.routers.whoami.entrypoints` | `websecure` | +| `traefik.http.routers.whoami.rule` | `Host(`whoami.${DOMAIN}`)` | +| `traefik.http.routers.whoami.tls` | `true` | + +### Networks (compose-level) + +- `web` (external) + +--- diff --git a/gitea/.env.example b/gitea/.env.example new file mode 100644 index 0000000..8b4c173 --- /dev/null +++ b/gitea/.env.example @@ -0,0 +1,3 @@ +# gitea/.env +# Copy to .env and fill in real values. NEVER commit .env. +# Add any Gitea-specific secrets here. diff --git a/gitea/docker-compose.yml b/gitea/docker-compose.yml new file mode 100644 index 0000000..f9498b7 --- /dev/null +++ b/gitea/docker-compose.yml @@ -0,0 +1,35 @@ +services: + gitea: + image: gitea/gitea:1.24.3 + container_name: gitea + restart: always + volumes: + - ${STORAGE_PATH}/gitea:/data + - /etc/timezone:/etc/timezone:ro + - /etc/localtime:/etc/localtime:ro + ports: + - "222:22" + networks: + - gitea + - web + env_file: + - ../.env + - .env + environment: + - USER_UID=${USER_UID} + - USER_GID=${USER_GID} + - GITEA__database__DB_TYPE=sqlite3 + - GITEA__server__ROOT_URL=https://gitea.${DOMAIN}/ + - USER=git + - GITEA_CUSTOM=/data/gitea + labels: + - "traefik.enable=true" + - "traefik.http.routers.gitea.entrypoints=websecure" + - "traefik.http.routers.gitea.rule=Host(`gitea.${DOMAIN}`)" + - "traefik.http.routers.gitea.tls.certresolver=letsencrypt" + - "traefik.http.services.gitea.loadbalancer.server.port=3000" + +networks: + gitea: + web: + external: true diff --git a/jellyfin/.env.example b/jellyfin/.env.example new file mode 100644 index 0000000..7691a3e --- /dev/null +++ b/jellyfin/.env.example @@ -0,0 +1,4 @@ +# jellyfin/.env +# Copy to .env and fill in real values. NEVER commit .env. + +JELLYFIN_PORT=8096 diff --git a/jellyfin/docker-compose.yml b/jellyfin/docker-compose.yml new file mode 100644 index 0000000..46221a6 --- /dev/null +++ b/jellyfin/docker-compose.yml @@ -0,0 +1,25 @@ +services: + jellyfin: + image: jellyfin/jellyfin:latest + container_name: jellyfin + restart: unless-stopped + volumes: + - ${STORAGE_PATH}/jellyfin/config:/config + - ${STORAGE_PATH}/jellyfin/cache:/cache + - ${MEDIA_PATH}:/media + ports: + - "${JELLYFIN_PORT:-8096}:8096" + networks: + - web + env_file: + - ../.env + labels: + - "traefik.enable=true" + - "traefik.http.routers.jellyfin.entrypoints=websecure" + - "traefik.http.routers.jellyfin.rule=Host(`jellyfin.${DOMAIN}`)" + - "traefik.http.routers.jellyfin.tls.certresolver=letsencrypt" + - "traefik.http.services.jellyfin.loadbalancer.server.port=8096" + +networks: + web: + external: true diff --git a/mkdocs.yml b/mkdocs.yml new file mode 100644 index 0000000..2021448 --- /dev/null +++ b/mkdocs.yml @@ -0,0 +1,18 @@ +site_name: Homelab Documentation +nav: + - Home: index.md + - Recovery Reference: recovery.md + - Services: + - Traefik: services/traefik.md + - Gitea: services/gitea.md + - Nextcloud: services/nextcloud.md + - qBittorrent: services/qbittorrent.md + - Jellyfin: services/jellyfin.md + - Devbox: services/devbox.md + - Obsidian: services/obsidian.md + - N8N: services/n8n.md + - Supersync: services/supersync.md +theme: + name: material + palette: + scheme: slate diff --git a/nextcloud/.env.example b/nextcloud/.env.example new file mode 100644 index 0000000..08a1226 --- /dev/null +++ b/nextcloud/.env.example @@ -0,0 +1,4 @@ +# nextcloud/.env +# Copy to .env and fill in real values. NEVER commit .env. + +AIO_PORT=8081 diff --git a/nextcloud/docker-compose.yml b/nextcloud/docker-compose.yml new file mode 100644 index 0000000..f487f48 --- /dev/null +++ b/nextcloud/docker-compose.yml @@ -0,0 +1,27 @@ +services: + nextcloud-aio-mastercontainer: + image: ghcr.io/nextcloud-releases/all-in-one:latest + container_name: nextcloud-aio-mastercontainer + restart: always + ports: + - "${AIO_PORT:-8081}:8080" + volumes: + - nextcloud_aio_mastercontainer:/mnt/docker-aio-config + - /var/run/docker.sock:/var/run/docker.sock:ro + environment: + - APACHE_PORT=11000 + - NEXTCLOUD_DATADIR=${STORAGE_PATH}/nextcloud + - APACHE_ADDITIONAL_NETWORK=web + networks: + - nextcloud-aio + - web + env_file: + - ../.env + +volumes: + nextcloud_aio_mastercontainer: + +networks: + nextcloud-aio: + web: + external: true diff --git a/obsidian-livesync/.env.example b/obsidian-livesync/.env.example new file mode 100644 index 0000000..9c0a43c --- /dev/null +++ b/obsidian-livesync/.env.example @@ -0,0 +1,6 @@ +# obsidian/.env +# Copy to .env and fill in real values. NEVER commit .env. + +COUCHDB_USER=admin +COUCHDB_PASSWORD=REPLACE_WITH_STRONG_PASSWORD +COUCHDB_PORT=5984 diff --git a/obsidian-livesync/docker-compose.yml b/obsidian-livesync/docker-compose.yml new file mode 100644 index 0000000..61adab1 --- /dev/null +++ b/obsidian-livesync/docker-compose.yml @@ -0,0 +1,15 @@ +services: + couchdb: + image: couchdb:latest + container_name: obsidian-livesync + restart: unless-stopped + ports: + - "${COUCHDB_PORT:-5984}:5984" + volumes: + - ${STORAGE_PATH}/obsidian:/opt/couchdb/data + env_file: + - ../.env + - .env + environment: + - COUCHDB_USER=${COUCHDB_USER:-admin} + - COUCHDB_PASSWORD=${COUCHDB_PASSWORD} diff --git a/qbittorrent/.env.example b/qbittorrent/.env.example new file mode 100644 index 0000000..423f8c4 --- /dev/null +++ b/qbittorrent/.env.example @@ -0,0 +1,8 @@ +# qbittorrent/.env +# Copy to .env and fill in real values. NEVER commit .env. +# WireGuard private key from ProtonVPN + +WIREGUARD_PRIVATE_KEY=REPLACE_WITH_YOUR_WIREGUARD_PRIVATE_KEY + +QBITTORRENT_PORT=8080 +JACKETT_PORT=9117 diff --git a/qbittorrent/docker-compose.yml b/qbittorrent/docker-compose.yml new file mode 100644 index 0000000..04e858b --- /dev/null +++ b/qbittorrent/docker-compose.yml @@ -0,0 +1,61 @@ +services: + gluetun: + image: qmcgaw/gluetun + container_name: qbittorrent_gluetun + restart: unless-stopped + cap_add: + - NET_ADMIN + devices: + - /dev/net/tun:/dev/net/tun + volumes: + - ${STORAGE_PATH}/qbittorrent/gluetun:/gluetun + - ${STORAGE_PATH}/qbittorrent/wireguard:/gluetun/wireguard + ports: + - "${QBITTORRENT_PORT:-8080}:8080" + - "${JACKETT_PORT:-9117}:9117" + networks: + - qbittorrent + env_file: + - ../.env + - .env + environment: + - VPN_SERVICE_PROVIDER=protonvpn + - VPN_TYPE=wireguard + - VPN_PORT_FORWARDING=on + - TZ=${TZ} + - SERVER_COUNTRIES=New Zealand + - PORT_FORWARD_ONLY=on + - VPN_PORT_FORWARDING_UP_COMMAND=/bin/sh -c 'wget -O- --post-data "json={\"listen_port\":{{PORTS}}}" http://localhost:8080/api/v2/app/setPreferences 2>/dev/null || true' + + qbittorrent: + image: lscr.io/linuxserver/qbittorrent:latest + container_name: qbittorrent + restart: unless-stopped + network_mode: "service:gluetun" + depends_on: + - gluetun + environment: + - TZ=${TZ} + - WEBUI_PORT=8080 + - PUID=${USER_UID} + - PGID=${USER_GID} + volumes: + - ${STORAGE_PATH}/qbittorrent/config:/config + - ${MEDIA_PATH}/torrents:/downloads + + jackett: + image: linuxserver/jackett:latest + container_name: jackett + restart: unless-stopped + network_mode: "service:gluetun" + depends_on: + - gluetun + environment: + - TZ=${TZ} + - PUID=${USER_UID} + - PGID=${USER_GID} + volumes: + - ${STORAGE_PATH}/qbittorrent/config/jackett:/config + +networks: + qbittorrent: diff --git a/scripts/__pycache__/generate_docs.cpython-313.pyc b/scripts/__pycache__/generate_docs.cpython-313.pyc new file mode 100644 index 0000000000000000000000000000000000000000..f938b14d0c2128c78d88d44fada4de8efe443a08 GIT binary patch literal 23068 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 b(W@#9H!nsr8rP?|{`W>7X)wH&!{Gk|Sx;YI literal 0 HcmV?d00001 diff --git a/scripts/generate_docs.py b/scripts/generate_docs.py new file mode 100755 index 0000000..b00297a --- /dev/null +++ b/scripts/generate_docs.py @@ -0,0 +1,473 @@ +#!/usr/bin/env python3 +""" +Auto-generate markdown docs from docker-compose.yml files. +Usage: python3 scripts/generate_docs.py +""" +import yaml +import os +import sys +from pathlib import Path + +REPO_ROOT = Path(__file__).parent.parent +SERVICES_DIR = REPO_ROOT +DOCS_DIR = REPO_ROOT / "docs" / "services" + +# Load root .env.example for shared variables +def load_root_env_example(): + env_vars = {} + env_file = REPO_ROOT / ".env.example" + if env_file.exists(): + for line in env_file.read_text().splitlines(): + line = line.strip() + if line and not line.startswith("#") and "=" in line: + key, _, val = line.partition("=") + env_vars[key.strip()] = val.strip() + return env_vars + +# Load service .env.example +def load_service_env_example(service_dir): + env_vars = {} + env_file = service_dir / ".env.example" + if env_file.exists(): + for line in env_file.read_text().splitlines(): + line = line.strip() + if line and not line.startswith("#") and "=" in line: + key, _, val = line.partition("=") + env_vars[key.strip()] = val.strip() + return env_vars + + +def format_volumes(volumes): + if not volumes: + return [] + rows = [] + for v in volumes: + if isinstance(v, str): + parts = v.split(":") + if len(parts) >= 2: + host = parts[0] + container = parts[1] + mode = parts[2] if len(parts) > 2 else "rw" + rows.append((host, container, mode)) + else: + rows.append((v, "-", "-")) + elif isinstance(v, dict): + source = v.get("source", v.get("type", "")) + target = v.get("target", "") + mode = v.get("mode", "rw") + rows.append((source, target, mode)) + return rows + + +def format_ports(ports): + if not ports: + return [] + rows = [] + for p in ports: + if isinstance(p, str): + # Handle protocol suffix like "8080:8080/tcp" + proto = "tcp" + port_str = p + if "/" in p: + port_str, proto = p.rsplit("/", 1) + # Use rsplit to handle ${VAR:-default}:port correctly + parts = port_str.rsplit(":", 1) + if len(parts) == 2: + rows.append((parts[0], parts[1], proto)) + else: + rows.append((parts[0], parts[0], proto)) + elif isinstance(p, dict): + host = str(p.get("published", "")) + container = str(p.get("target", "")) + proto = p.get("protocol", "tcp") + rows.append((host, container, proto)) + return rows + + +def format_env(env_list): + if not env_list: + return [] + rows = [] + for e in env_list: + if isinstance(e, str) and "=" in e: + key, _, val = e.partition("=") + rows.append((key.strip(), val.strip())) + elif isinstance(e, str): + rows.append((e.strip(), "")) + return rows + + +def format_labels(labels): + if not labels: + return [] + rows = [] + if isinstance(labels, list): + for l in labels: + if isinstance(l, str) and "=" in l: + key, _, val = l.partition("=") + rows.append((key.strip(), val.strip())) + elif isinstance(l, dict): + for k, v in l.items(): + rows.append((k, str(v))) + elif isinstance(labels, dict): + for k, v in labels.items(): + rows.append((k, str(v))) + return rows + + +def format_command(cmd): + if not cmd: + return [] + if isinstance(cmd, str): + return [cmd] + if isinstance(cmd, list): + return [str(c) for c in cmd] + return [] + + +def format_networks(networks): + if not networks: + return [] + rows = [] + if isinstance(networks, dict): + for name, cfg in networks.items(): + external = False + if isinstance(cfg, dict): + external = cfg.get("external", False) + rows.append((name, "external" if external else "internal")) + elif isinstance(networks, list): + for n in networks: + if isinstance(n, str): + rows.append((n, "internal")) + return rows + + +def format_depends(depends): + if not depends: + return [] + if isinstance(depends, list): + return [str(d) for d in depends] + if isinstance(depends, dict): + return list(depends.keys()) + return [] + + +def format_cap_add(caps): + if not caps: + return [] + if isinstance(caps, list): + return [str(c) for c in caps] + return [] + + +def format_security(sec): + if not sec: + return [] + if isinstance(sec, list): + return [str(s) for s in sec] + return [] + + +def generate_service_md(service_name, compose_data, root_env, service_env): + lines = [] + lines.append(f"# {service_name}") + lines.append("") + lines.append(f"> Auto-generated from `docker-compose.yml`") + lines.append("") + + services = compose_data.get("services", {}) + if not services: + lines.append("No services defined.") + return "\n".join(lines) + + for svc_name, svc in services.items(): + if len(services) > 1: + lines.append(f"## `{svc_name}`") + lines.append("") + + # Image + image = svc.get("image", "N/A") + container = svc.get("container_name", svc_name) + lines.append(f"| Field | Value |") + lines.append(f"|-------|-------|") + lines.append(f"| **Image** | `{image}` |") + lines.append(f"| **Container** | `{container}` |") + lines.append(f"| **Restart** | `{svc.get('restart', 'no')}` |") + lines.append("") + + # Command + cmd = format_command(svc.get("command")) + if cmd: + lines.append("### Command") + lines.append("") + lines.append("```") + lines.append(" ".join(cmd)) + lines.append("```") + lines.append("") + + # Environment + env = format_env(svc.get("environment")) + env_file = svc.get("env_file") + env_from_file = [] + if env_file: + if isinstance(env_file, str): + env_file = [env_file] + env_from_file = env_file + + if env or env_from_file: + lines.append("### Environment") + lines.append("") + if env_from_file: + lines.append("**Env files:** " + ", ".join(f"`{e}`" for e in env_from_file)) + lines.append("") + if env: + lines.append("| Variable | Value |") + lines.append("|----------|-------|") + for key, val in env: + if "${" in val or val == "": + lines.append(f"| `{key}` | `{val}` |") + else: + lines.append(f"| `{key}` | `{val}` |") + lines.append("") + + # Service secrets from .env.example + if service_env: + lines.append("### Secrets (from `.env.example`)") + lines.append("") + lines.append("These variables must be set in the service's `.env` file:") + lines.append("") + lines.append("| Variable | Default |") + lines.append("|----------|---------|") + for key, val in service_env.items(): + lines.append(f"| `{key}` | `{val}` |") + lines.append("") + + # Ports + ports = format_ports(svc.get("ports")) + if ports: + lines.append("### Ports") + lines.append("") + lines.append("| Host | Container | Protocol |") + lines.append("|------|-----------|----------|") + for host, container, proto in ports: + lines.append(f"| `{host}` | `{container}` | {proto} |") + lines.append("") + + # Volumes + volumes = format_volumes(svc.get("volumes")) + if volumes: + lines.append("### Volumes") + lines.append("") + lines.append("| Host Path | Container Path | Mode |") + lines.append("|-----------|----------------|------|") + for host, container, mode in volumes: + lines.append(f"| `{host}` | `{container}` | {mode} |") + lines.append("") + + # Networks + nets = format_networks(svc.get("networks")) + if nets: + lines.append("### Networks") + lines.append("") + for name, ntype in nets: + lines.append(f"- `{name}` ({ntype})") + lines.append("") + + # Depends on + depends = format_depends(svc.get("depends_on")) + if depends: + lines.append("### Dependencies") + lines.append("") + for d in depends: + lines.append(f"- `{d}`") + lines.append("") + + # Capabilities + caps = format_cap_add(svc.get("cap_add")) + if caps: + lines.append("### Capabilities") + lines.append("") + lines.append(", ".join(f"`{c}`" for c in caps)) + lines.append("") + + # Security + sec = format_security(svc.get("security_opt")) + if sec: + lines.append("### Security Options") + lines.append("") + for s in sec: + lines.append(f"- `{s}`") + lines.append("") + + # Labels + labels = format_labels(svc.get("labels")) + if labels: + lines.append("### Labels") + lines.append("") + lines.append("| Key | Value |") + lines.append("|-----|-------|") + for key, val in labels: + lines.append(f"| `{key}` | `{val}` |") + lines.append("") + + # Devices + devices = svc.get("devices") + if devices: + lines.append("### Devices") + lines.append("") + for d in devices: + if isinstance(d, str): + lines.append(f"- `{d}`") + lines.append("") + + # tmpfs + tmpfs = svc.get("tmpfs") + if tmpfs: + lines.append("### Tmpfs") + lines.append("") + if isinstance(tmpfs, list): + for t in tmpfs: + lines.append(f"- `{t}`") + lines.append("") + + # shm_size + shm = svc.get("shm_size") + if shm: + lines.append(f"### SHM Size: `{shm}`") + lines.append("") + + # resource limits + mem = svc.get("mem_limit") + swap = svc.get("memswap_limit") + if mem or swap: + lines.append("### Resources") + lines.append("") + if mem: + lines.append(f"- Memory limit: `{mem}`") + if swap: + lines.append(f"- Memory+Swap limit: `{swap}`") + lines.append("") + + # Compose-level volumes (named) + comp_volumes = compose_data.get("volumes") + if comp_volumes and isinstance(comp_volumes, dict): + lines.append("### Named Volumes (compose-level)") + lines.append("") + for vname, vcfg in comp_volumes.items(): + external = False + if isinstance(vcfg, dict): + external = vcfg.get("external", False) + ntype = "external" if external else "managed by compose" + lines.append(f"- `{vname}` ({ntype})") + lines.append("") + + # Compose-level networks + comp_nets = compose_data.get("networks") + if comp_nets and isinstance(comp_nets, dict): + lines.append("### Networks (compose-level)") + lines.append("") + for nname, ncfg in comp_nets.items(): + external = False + if isinstance(ncfg, dict): + external = ncfg.get("external", False) + ntype = "external" if external else "internal" + lines.append(f"- `{nname}` ({ntype})") + lines.append("") + + lines.append("---") + lines.append("") + + return "\n".join(lines) + + +def generate_index(all_services): + lines = [] + lines.append("# Homelab Documentation") + lines.append("") + lines.append("> Auto-generated from `docker-compose.yml` files") + lines.append("") + + lines.append("## Data Classification") + lines.append("") + lines.append("| Type | Example | Git Repo? | Backup? | Location |") + lines.append("|------|---------|-----------|---------|----------|") + lines.append("| **Source Configs** | `docker-compose.yml`, `.env.example`, `Makefile` | Yes | No | `/srv/homelab-infra` |") + lines.append("| **Service Secrets** | `.env` (DB passwords, API keys) | No | Yes | `/srv/homelab-infra//.env` |") + lines.append("| **Runtime Configs** | `acme.json`, service configs | No | Yes | `/mnt/storage/docker-data/` |") + lines.append("| **Persistent Data** | DB data, uploads, media | No | Yes | `/mnt/storage/docker-data/` |") + lines.append("") + + lines.append("## Services") + lines.append("") + lines.append("| Service | Image | Status |") + lines.append("|---------|-------|--------|") + for name, data in sorted(all_services.items()): + services = data.get("services", {}) + images = [] + for svc in services.values(): + img = svc.get("image", "N/A") + images.append(f"`{img}`") + status = "active" if "planned" not in name.lower() else "planned" + lines.append(f"| [{name}](services/{name}.md) | {', '.join(images)} | {status} |") + lines.append("") + + lines.append("## Quick Start") + lines.append("") + lines.append("```bash") + lines.append("cp .env.example .env") + lines.append("for svc in */; do [ -f \"$svc/.env.example\" ] && cp \"$svc/.env.example\" \"$svc/.env\"; done") + lines.append("docker network create web") + lines.append("make up") + lines.append("```") + lines.append("") + + return "\n".join(lines) + + +def main(): + root_env = load_root_env_example() + all_services = {} + + # Find all service directories with docker-compose.yml + for item in sorted(SERVICES_DIR.iterdir()): + if not item.is_dir(): + continue + compose_file = item / "docker-compose.yml" + if not compose_file.exists(): + continue + # Skip scripts/docs directories + if item.name in ("scripts", "docs", "__pycache__"): + continue + + service_name = item.name + try: + with open(compose_file) as f: + compose_data = yaml.safe_load(f) + except Exception as e: + print(f"Error parsing {compose_file}: {e}", file=sys.stderr) + continue + + if not compose_data: + continue + + service_env = load_service_env_example(item) + all_services[service_name] = compose_data + + # Generate service doc + md = generate_service_md(service_name, compose_data, root_env, service_env) + out_file = DOCS_DIR / f"{service_name}.md" + out_file.parent.mkdir(parents=True, exist_ok=True) + out_file.write_text(md) + print(f"Generated {out_file.relative_to(REPO_ROOT)}") + + # Generate index + index_md = generate_index(all_services) + index_file = REPO_ROOT / "docs" / "index.md" + index_file.write_text(index_md) + print(f"Generated {index_file.relative_to(REPO_ROOT)}") + + print(f"\nDone. Generated docs for {len(all_services)} services.") + + +if __name__ == "__main__": + main() diff --git a/traefik/.env.example b/traefik/.env.example new file mode 100644 index 0000000..ba9e547 --- /dev/null +++ b/traefik/.env.example @@ -0,0 +1,6 @@ +# traefik/.env +# Copy to .env and fill in real values. NEVER commit .env. + +TRAEFIK_DASHBOARD_PORT=8082 +ACME_EMAIL=letsencrypt@example.com +DASHBOARD_BASIC_AUTH=admin:$$apr1$$changeme$$REPLACE_WITH_HTPASSWD_HASH diff --git a/traefik/docker-compose.yml b/traefik/docker-compose.yml new file mode 100644 index 0000000..006b9d0 --- /dev/null +++ b/traefik/docker-compose.yml @@ -0,0 +1,45 @@ +services: + traefik: + image: traefik:v3.6 + container_name: traefik + restart: unless-stopped + ports: + - "80:80" + - "443:443" + - "${TRAEFIK_DASHBOARD_PORT:-8082}:8080" + volumes: + - /var/run/docker.sock:/var/run/docker.sock:ro + - ${STORAGE_PATH}/traefik/certs:/certs:rw + - ${STORAGE_PATH}/traefik/dynamic:/dynamic:ro + networks: + - web + security_opt: + - no-new-privileges:true + labels: + - "traefik.enable=true" + - "traefik.http.middlewares.dashboard-auth.basicauth.users=${DASHBOARD_BASIC_AUTH}" + - "traefik.http.routers.dashboard.entrypoints=websecure" + - "traefik.http.routers.dashboard.middlewares=dashboard-auth@docker" + - "traefik.http.routers.dashboard.rule=Host(`dashboard.${DOMAIN}`)" + - "traefik.http.routers.dashboard.service=api@internal" + - "traefik.http.routers.dashboard.tls=true" + command: + - "--entrypoints.web.address=:80" + - "--entrypoints.web.http.redirections.entrypoint.to=websecure" + - "--entrypoints.web.http.redirections.entrypoint.scheme=https" + - "--entrypoints.websecure.address=:443" + - "--providers.docker=true" + - "--providers.docker.exposedbydefault=false" + - "--providers.docker.network=web" + - "--certificatesresolvers.letsencrypt.acme.httpchallenge=true" + - "--certificatesresolvers.letsencrypt.acme.httpchallenge.entrypoint=web" + - "--certificatesresolvers.letsencrypt.acme.email=${ACME_EMAIL}" + - "--certificatesresolvers.letsencrypt.acme.storage=/certs/acme.json" + - "--metrics.prometheus=true" + - "--accesslog=true" + - "--providers.file.directory=/dynamic" + - "--providers.file.watch=true" + +networks: + web: + external: true diff --git a/whoami/docker-compose.yml b/whoami/docker-compose.yml new file mode 100644 index 0000000..a33e08e --- /dev/null +++ b/whoami/docker-compose.yml @@ -0,0 +1,16 @@ +services: + whoami: + image: traefik/whoami + container_name: whoami + restart: unless-stopped + networks: + - web + labels: + - "traefik.enable=true" + - "traefik.http.routers.whoami.entrypoints=websecure" + - "traefik.http.routers.whoami.rule=Host(`whoami.${DOMAIN}`)" + - "traefik.http.routers.whoami.tls=true" + +networks: + web: + external: true