commit 716baafbc16807a62f2776be056bb969afeb9f32 Author: 1jamesthompson1 <1jamesthompson1@gmail.com> Date: Mon Mar 23 16:06:42 2026 +1300 AI given structure 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 0000000..f938b14 Binary files /dev/null and b/scripts/__pycache__/generate_docs.cpython-313.pyc differ 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