diff --git a/.env.example b/.env.example index ab86b655..51422109 100644 --- a/.env.example +++ b/.env.example @@ -17,8 +17,14 @@ ACME_EMAIL=you@example.com # Let's Encrypt notification email # TRAEFIK # ----------------------------------------------------------------------------- TRAEFIK_DASHBOARD_USER=admin -# Generate password hash: echo $(htpasswd -nb admin yourpassword) | sed -e s/\$/\$\$/g -TRAEFIK_DASHBOARD_PASSWORD_HASH= +# Generate BasicAuth credentials: +# htpasswd -nbB admin 'your-password' | sed -e 's/\$/\$\$/g' +TRAEFIK_AUTH= +# letsencrypt = HTTP-01 challenge, letsencryptdns = DNS challenge +TRAEFIK_CERT_RESOLVER=letsencrypt +ACME_DNS_PROVIDER=cloudflare +CF_DNS_API_TOKEN= +DOCKER_API_VERSION=1.43 # ----------------------------------------------------------------------------- # PORTAINER @@ -105,6 +111,16 @@ OLLAMA_GPU_ENABLED=false # Set to true if you have NVIDIA GPU # ----------------------------------------------------------------------------- GOTIFY_PASSWORD= # REQUIRED NTFY_AUTH_ENABLED=true +# Optional Watchtower integration with Gotify or ntfy via Shoutrrr. +# Examples: +# WATCHTOWER_NOTIFICATIONS=shoutrrr +# WATCHTOWER_NOTIFICATION_URL=gotify://gotify.example.com/token +# WATCHTOWER_NOTIFICATION_URL=ntfy://ntfy.example.com/homelab-updates +WATCHTOWER_NOTIFICATIONS= +WATCHTOWER_NOTIFICATION_URL= +WATCHTOWER_NOTIFICATIONS_LEVEL=info +WATCHTOWER_NOTIFICATION_REPORT=true +WATCHTOWER_NOTIFICATION_LOG_STDOUT=false # ----------------------------------------------------------------------------- # NETWORK PROXY (optional — for CN users with local proxy) diff --git a/README.md b/README.md index a249ae61..950c7013 100644 --- a/README.md +++ b/README.md @@ -21,11 +21,11 @@ git clone https://github.com/YOUR_USERNAME/homelab-stack.git cd homelab-stack -# 2. Check dependencies & setup environment +# 2. Check dependencies, configure the environment, and launch base infrastructure ./install.sh -# 3. Launch base infrastructure -docker compose -f docker-compose.base.yml up -d +# 3. Check base infrastructure +docker compose --env-file .env -f stacks/base/docker-compose.yml ps # 4. Launch any stack ./scripts/stack-manager.sh start media @@ -41,7 +41,7 @@ docker compose -f docker-compose.base.yml up -d | Stack | Services | Bounty | |-------|----------|--------| -| [Base Infrastructure](stacks/base/) | Traefik, Portainer, Watchtower | ✅ Core | +| [Base Infrastructure](stacks/base/) | Traefik, Portainer, Watchtower, Docker Socket Proxy | ✅ Core | | [Media](stacks/media/) | Jellyfin, Sonarr, Radarr, Prowlarr, qBittorrent, Jellyseerr | [#2](../../issues/2) | | [Storage](stacks/storage/) | Nextcloud, MinIO, FileBrowser, Syncthing | [#3](../../issues/3) | | [Monitoring](stacks/monitoring/) | Grafana, Prometheus, Loki, Alertmanager, Uptime Kuma | [#4](../../issues/4) | @@ -86,12 +86,12 @@ All stacks share: ``` homelab-stack/ ├── install.sh # Entry point — env check + guided setup -├── docker-compose.base.yml # Core infrastructure ├── .env.example # All configurable variables ├── BOUNTY.md # Bounty task overview │ ├── stacks/ # One directory per service group │ ├── media/ +│ ├── base/ # Core infrastructure compose │ ├── storage/ │ ├── monitoring/ │ ├── network/ diff --git a/config/traefik/dynamic/middlewares.yml b/config/traefik/dynamic/middlewares.yml index 2d1367ee..1f47f4a2 100644 --- a/config/traefik/dynamic/middlewares.yml +++ b/config/traefik/dynamic/middlewares.yml @@ -3,47 +3,15 @@ # All middlewares defined here are available project-wide via @file provider. # # Usage in docker-compose labels: -# traefik.http.routers..middlewares=authentik@file,security-headers@file +# traefik.http.routers..middlewares=security-headers@file,rate-limit@file +# Dashboard BasicAuth is defined in stacks/base/docker-compose.yml so it can use +# TRAEFIK_AUTH from .env without writing generated secrets into this directory. +# Authentik ForwardAuth lives in config/traefik/dynamic/authentik.yml. # ============================================================================= http: middlewares: - # ------------------------------------------------------------------------- - # BasicAuth — Traefik Dashboard protection - # Generate hash: echo $(htpasswd -nb admin PASSWORD) | sed -e s/\$/\$\$/g - # Then set TRAEFIK_DASHBOARD_PASSWORD_HASH in .env - # ------------------------------------------------------------------------- - traefik-auth: - basicAuth: - usersFile: /dynamic/.htpasswd - removeHeader: true # Strip Authorization header from upstream request - - # ------------------------------------------------------------------------- - # Authentik ForwardAuth - # Protects any service — redirects unauthenticated requests to SSO login. - # Requires: SSO stack running (stacks/sso/docker-compose.yml) - # - # Usage: add to any router: - # traefik.http.routers..middlewares=authentik@file - # ------------------------------------------------------------------------- - authentik: - forwardAuth: - address: "http://authentik-server:9000/outpost.goauthentik.io/auth/traefik" - trustForwardHeader: true - authResponseHeaders: - - X-authentik-username - - X-authentik-groups - - X-authentik-email - - X-authentik-name - - X-authentik-uid - - X-authentik-jwt - - X-authentik-meta-jwks - - X-authentik-meta-outpost - - X-authentik-meta-provider - - X-authentik-meta-app - - X-authentik-meta-version - # ------------------------------------------------------------------------- # Security Headers — applied to all public-facing services # Reference: https://securityheaders.com diff --git a/config/traefik/traefik.local.yml b/config/traefik/traefik.local.yml index 36b05f20..7cd32b87 100644 --- a/config/traefik/traefik.local.yml +++ b/config/traefik/traefik.local.yml @@ -16,9 +16,10 @@ entryPoints: providers: docker: - endpoint: "unix:///var/run/docker.sock" + endpoint: "tcp://socket-proxy:2375" exposedByDefault: false network: proxy + constraints: "Label(`traefik.enable`, `true`)" watch: true file: directory: /dynamic diff --git a/config/traefik/traefik.yml b/config/traefik/traefik.yml index 3e71c28f..e4ba23d8 100644 --- a/config/traefik/traefik.yml +++ b/config/traefik/traefik.yml @@ -2,9 +2,8 @@ # Traefik — Static Configuration # Docs: https://doc.traefik.io/traefik/reference/static-configuration/file/ # -# NOTE: Traefik static config does NOT support environment variable substitution. -# Edit this file directly or use envsubst in your deploy script. -# Values marked [EDIT] must be changed before first run. +# NOTE: Values in this static file can be overridden by TRAEFIK_* environment +# variables supplied by stacks/base/docker-compose.yml. # ============================================================================= # ----------------------------------------------------------------------------- @@ -59,19 +58,19 @@ entryPoints: certificatesResolvers: letsencrypt: acme: - email: "admin@yourdomain.com" # [EDIT] your email + email: "admin@example.com" # Overridden by ACME_EMAIL via compose env storage: /acme.json caServer: "https://acme-v02.api.letsencrypt.org/directory" httpChallenge: entryPoint: web - letsencrypt-dns: + letsencryptdns: acme: - email: "admin@yourdomain.com" # [EDIT] same email + email: "admin@example.com" # Overridden by ACME_EMAIL via compose env storage: /acme.json caServer: "https://acme-v02.api.letsencrypt.org/directory" dnsChallenge: - provider: cloudflare # [EDIT] your DNS provider + provider: cloudflare # Override with ACME_DNS_PROVIDER resolvers: - "1.1.1.1:53" - "8.8.8.8:53" @@ -81,9 +80,10 @@ certificatesResolvers: # ----------------------------------------------------------------------------- providers: docker: - endpoint: "unix:///var/run/docker.sock" + endpoint: "tcp://socket-proxy:2375" exposedByDefault: false # Containers must opt-in with traefik.enable=true network: proxy # Default network for routing + constraints: "Label(`traefik.enable`, `true`)" watch: true file: diff --git a/homelab.md b/homelab.md index 0d7f909f..72d1e9c4 100644 --- a/homelab.md +++ b/homelab.md @@ -390,9 +390,10 @@ push notification"] ### base | Service | Image | URL | |---------|-------|-----| -| Traefik | traefik:v3.1.6 | traefik.DOMAIN | -| Portainer | portainer-ce:2.21.4 | portainer.DOMAIN | +| Traefik | traefik:v3.6.1 | traefik.DOMAIN | +| Portainer | portainer/portainer-ce:2.21.3 | portainer.DOMAIN | | Watchtower | containrrr/watchtower:1.7.1 | -- | +| Docker Socket Proxy | tecnativa/docker-socket-proxy:0.2.0 | internal | Config: config/traefik/traefik.yml (prod), traefik.local.yml (dev) diff --git a/install.sh b/install.sh old mode 100644 new mode 100755 index e911d519..68c86da9 --- a/install.sh +++ b/install.sh @@ -5,6 +5,9 @@ set -euo pipefail IFS=$'\n\t' +ROOT_DIR=$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd) +cd "$ROOT_DIR" + RED='\033[0;31m'; GREEN='\033[0;32m'; YELLOW='\033[1;33m' BLUE='\033[0;34m'; BOLD='\033[1m'; NC='\033[0m' @@ -34,32 +37,27 @@ echo -e "${BOLD} S T A C K v1.0.0${NC}" echo -e "" # --------------------------------------------------------------------------- -# Step 1: Check dependencies -# --------------------------------------------------------------------------- -log_step "Checking dependencies" -bash "$(dirname "$0")/scripts/check-deps.sh" - -# --------------------------------------------------------------------------- -# Step 2: CN network detection +# Step 1: Check system dependencies # --------------------------------------------------------------------------- -log_step "Network environment detection" -bash "$(dirname "$0")/scripts/check-deps.sh" --network-check +log_step "Checking system dependencies" +bash "$ROOT_DIR/scripts/check-deps.sh" --preflight # --------------------------------------------------------------------------- -# Step 3: Setup environment +# Step 2: Setup environment # --------------------------------------------------------------------------- log_step "Environment configuration" if [[ ! -f .env ]]; then - bash "$(dirname "$0")/scripts/setup-env.sh" + bash "$ROOT_DIR/scripts/setup-env.sh" else log_warn ".env already exists, skipping setup. Remove it to reconfigure." fi # --------------------------------------------------------------------------- -# Step 4: Create data directories +# Step 3: Create data directories and base prerequisites # --------------------------------------------------------------------------- -log_step "Creating data directories" +log_step "Creating data directories and base prerequisites" mkdir -p \ + config/traefik \ data/traefik/certs \ data/portainer \ data/prometheus \ @@ -70,13 +68,21 @@ mkdir -p \ data/gitea \ data/vaultwarden -chmod 600 config/traefik/acme.json 2>/dev/null || touch config/traefik/acme.json && chmod 600 config/traefik/acme.json +touch config/traefik/acme.json +chmod 600 config/traefik/acme.json +docker network inspect proxy >/dev/null 2>&1 || docker network create proxy >/dev/null + +# --------------------------------------------------------------------------- +# Step 4: Validate configured stack +# --------------------------------------------------------------------------- +log_step "Validating configured stack" +bash "$ROOT_DIR/scripts/check-deps.sh" # --------------------------------------------------------------------------- # Step 5: Launch base infrastructure # --------------------------------------------------------------------------- log_step "Launching base infrastructure" -docker compose -f docker-compose.base.yml up -d +docker compose --env-file .env -f stacks/base/docker-compose.yml up -d log_info "" log_info "${GREEN}${BOLD}✓ Base infrastructure is up!${NC}" diff --git a/scripts/backup-databases.sh b/scripts/backup-databases.sh old mode 100644 new mode 100755 diff --git a/scripts/backup.sh b/scripts/backup.sh old mode 100644 new mode 100755 diff --git a/scripts/check-deps.sh b/scripts/check-deps.sh old mode 100644 new mode 100755 index b209130d..a166c89c --- a/scripts/check-deps.sh +++ b/scripts/check-deps.sh @@ -17,12 +17,35 @@ NC='\033[0m' PASS=0 FAIL=0 WARN=0 +MODE="${1:-full}" -log_pass() { echo -e " ${GREEN}[PASS]${NC} $*"; ((PASS++)); } -log_fail() { echo -e " ${RED}[FAIL]${NC} $*"; ((FAIL++)); } -log_warn() { echo -e " ${YELLOW}[WARN]${NC} $*"; ((WARN++)); } +log_pass() { echo -e " ${GREEN}[PASS]${NC} $*"; PASS=$((PASS + 1)); } +log_fail() { echo -e " ${RED}[FAIL]${NC} $*"; FAIL=$((FAIL + 1)); } +log_warn() { echo -e " ${YELLOW}[WARN]${NC} $*"; WARN=$((WARN + 1)); } log_info() { echo -e " ${BLUE}[INFO]${NC} $*"; } +usage() { + cat <<'EOF' +Usage: scripts/check-deps.sh [--preflight] + + --preflight Check host tools and capacity only. This is safe to run before + the first .env, acme.json, and proxy network are created. +EOF +} + +first_line() { + local text="$1" + printf '%s' "${text%%$'\n'*}" +} + +cmd_version() { + local cmd="$1" + case "$cmd" in + openssl) "$cmd" version 2>&1 ;; + *) "$cmd" --version 2>&1 ;; + esac +} + # --------------------------------------------------------------------------- # Check: command exists # --------------------------------------------------------------------------- @@ -31,13 +54,25 @@ check_cmd() { local min_ver="${2:-}" if command -v "$cmd" &>/dev/null; then local ver - ver=$("$cmd" --version 2>&1 | head -1) + ver=$(first_line "$(cmd_version "$cmd" || true)") log_pass "$cmd found: $ver" else log_fail "$cmd not found — install it first" fi } +check_optional_cmd() { + local cmd="$1" + local install_hint="$2" + if command -v "$cmd" &>/dev/null; then + local ver + ver=$(first_line "$(cmd_version "$cmd" || true)") + log_pass "$cmd found: $ver" + else + log_warn "$cmd not found — $install_hint" + fi +} + # --------------------------------------------------------------------------- # Check: Docker version >= 24.0 # --------------------------------------------------------------------------- @@ -120,7 +155,7 @@ check_env_file() { if [[ -f "$env_path" ]]; then log_pass ".env file exists" # Check required vars - local required=(DOMAIN ACME_EMAIL TRAEFIK_DASHBOARD_USER TRAEFIK_DASHBOARD_PASSWORD_HASH TZ) + local required=(DOMAIN ACME_EMAIL TRAEFIK_AUTH TZ) for var in "${required[@]}"; do local val val=$(grep -E "^${var}=" "$env_path" | cut -d= -f2- | tr -d '\"' || true) @@ -165,8 +200,28 @@ check_disk() { # --------------------------------------------------------------------------- # Main # --------------------------------------------------------------------------- +case "$MODE" in + full | "") + ;; + --preflight) + ;; + -h | --help) + usage + exit 0 + ;; + *) + log_fail "Unknown option: $MODE" + usage + exit 1 + ;; +esac + echo -echo -e "${BLUE}=== HomeLab Stack — Dependency Check ===${NC}" +if [[ "$MODE" == "--preflight" ]]; then + echo -e "${BLUE}=== HomeLab Stack — System Preflight ===${NC}" +else + echo -e "${BLUE}=== HomeLab Stack — Dependency Check ===${NC}" +fi echo echo "[1/7] Docker" @@ -180,20 +235,26 @@ echo echo "[3/7] Required commands" check_cmd curl check_cmd openssl -check_cmd htpasswd || log_warn "htpasswd not found — install apache2-utils (Debian) or httpd-tools (RHEL)" +check_optional_cmd htpasswd "install apache2-utils (Debian) or httpd-tools (RHEL) to generate TRAEFIK_AUTH" echo -echo "[4/7] Proxy network" -check_proxy_network -echo +if [[ "$MODE" != "--preflight" ]]; then + echo "[4/7] Proxy network" + check_proxy_network + echo -echo "[5/7] ACME / TLS config" -check_acme_json -echo + echo "[5/7] ACME / TLS config" + check_acme_json + echo -echo "[6/7] Environment file" -check_env_file -echo + echo "[6/7] Environment file" + check_env_file + echo +else + echo "[4/7] First-run stack files" + log_info "Skipping .env, acme.json, and proxy network checks until setup creates them" + echo +fi echo "[7/7] Ports & disk" check_ports diff --git a/scripts/cn-pull.sh b/scripts/cn-pull.sh old mode 100644 new mode 100755 diff --git a/scripts/setup-authentik.sh b/scripts/setup-authentik.sh old mode 100644 new mode 100755 diff --git a/scripts/setup-env.sh b/scripts/setup-env.sh old mode 100644 new mode 100755 index f6faf733..9063f172 --- a/scripts/setup-env.sh +++ b/scripts/setup-env.sh @@ -16,19 +16,28 @@ log_info() { echo -e "${GREEN}[INFO]${RESET} $*"; } log_warn() { echo -e "${YELLOW}[WARN]${RESET} $*"; } log_error() { echo -e "${RED}[ERROR]${RESET} $*" >&2; } log_step() { echo; echo -e "${BOLD}${CYAN}==> $*${RESET}"; } -log_ask() { printf '%s' "${BOLD}${YELLOW}[?]${RESET} $* "; } +log_ask() { printf '%s' "${BOLD}${YELLOW}[?]${RESET} $* " >&2; } ask() { local prompt="$1" default="${2:-}" val log_ask "$prompt${default:+ [$default]}:" - read -r val + if ! read -r val; then + echo + log_error "No input received for '$prompt'. Run this script from an interactive shell or create .env from .env.example." + exit 1 + fi printf '%s' "${val:-$default}" } ask_secret() { local prompt="$1" val log_ask "$prompt (hidden):" - read -rs val; echo + if ! read -rs val; then + echo + log_error "No input received for '$prompt'. Run this script from an interactive shell or set TRAEFIK_AUTH manually." + exit 1 + fi + echo >&2 printf '%s' "$val" } @@ -45,6 +54,28 @@ set_env() { get_env() { grep -m1 "^${1}=" "$ENV_FILE" 2>/dev/null | cut -d= -f2- || true; } gen_secret() { LC_ALL=C tr -dc 'A-Za-z0-9!@#%^&*' /dev/null; then + auth=$(htpasswd -nbB "$user" "$pass" | compose_escape) + set_env TRAEFIK_AUTH "$auth" + elif command -v openssl &>/dev/null; then + hash=$(printf '%s' "$pass" | openssl passwd -apr1 -stdin) + auth=$(printf '%s:%s' "$user" "$hash" | compose_escape) + set_env TRAEFIK_AUTH "$auth" + log_warn 'htpasswd not found — generated TRAEFIK_AUTH with openssl apr1 fallback' + else + log_warn 'htpasswd and openssl are unavailable — set TRAEFIK_AUTH manually' + fi +} + main() { echo; echo "HomeLab Stack -- Environment Setup" [[ "${1:-}" == "--reset" ]] && { rm -f "$ENV_FILE"; log_info "Reset .env"; } @@ -58,20 +89,16 @@ main() { log_step "Traefik Dashboard" local user; user=$(ask 'Dashboard username' "$(get_env TRAEFIK_DASHBOARD_USER)") set_env TRAEFIK_DASHBOARD_USER "$user" - if [[ -z "$(get_env TRAEFIK_DASHBOARD_PASSWORD_HASH)" ]]; then + if [[ -z "$(get_env TRAEFIK_AUTH)" ]]; then local pass; pass=$(ask_secret 'Dashboard password') - if command -v htpasswd &>/dev/null; then - set_env TRAEFIK_DASHBOARD_PASSWORD_HASH "$(htpasswd -nbB "$user" "$pass" | sed 's/\$/\$\$/g')" - else - log_warn 'htpasswd not found — set TRAEFIK_DASHBOARD_PASSWORD_HASH manually' - fi + generate_traefik_auth "$user" "$pass" fi log_step "CN Mirror (Optional)" local cn; cn=$(ask 'Enable CN mirrors? (true/false)' "$(get_env CN_MODE)") set_env CN_MODE "$cn" - echo; log_info "Done. Next: cd stacks/base && docker compose up -d"; echo + echo; log_info "Done. Next: docker compose --env-file .env -f stacks/base/docker-compose.yml up -d"; echo } main "${@:-}" diff --git a/scripts/setup-media.sh b/scripts/setup-media.sh old mode 100644 new mode 100755 diff --git a/scripts/stack-manager.sh b/scripts/stack-manager.sh old mode 100644 new mode 100755 diff --git a/scripts/test-stacks.sh b/scripts/test-stacks.sh old mode 100644 new mode 100755 index ff92121c..c2defbf0 --- a/scripts/test-stacks.sh +++ b/scripts/test-stacks.sh @@ -57,6 +57,7 @@ port_check() { # ---- Tests ---- log_group "Base Infrastructure" +container_check docker-socket-proxy container_check traefik container_check portainer container_check watchtower diff --git a/stacks/base/.env.example b/stacks/base/.env.example new file mode 100644 index 00000000..dfb62f59 --- /dev/null +++ b/stacks/base/.env.example @@ -0,0 +1,34 @@ +# ============================================================================= +# HomeLab Stack — Base Infrastructure Environment +# Copy to stacks/base/.env for standalone base-stack deployment, or keep the +# same values in the repository-root .env when launching from the repo root. +# ============================================================================= + +# Required domain and certificate settings +DOMAIN=example.com +ACME_EMAIL=admin@example.com +TZ=Asia/Shanghai + +# Traefik dashboard BasicAuth credentials. +# Generate with: +# htpasswd -nbB admin 'your-password' | sed -e 's/\$/\$\$/g' +TRAEFIK_AUTH= + +# Certificate resolver: +# letsencrypt = HTTP-01 challenge on port 80 +# letsencryptdns = DNS challenge; requires provider credentials below +TRAEFIK_CERT_RESOLVER=letsencrypt +ACME_DNS_PROVIDER=cloudflare +CF_DNS_API_TOKEN= +DOCKER_API_VERSION=1.43 + +# Optional Watchtower notifications through Shoutrrr. +# Examples: +# WATCHTOWER_NOTIFICATIONS=shoutrrr +# WATCHTOWER_NOTIFICATION_URL=gotify://gotify.example.com/token +# WATCHTOWER_NOTIFICATION_URL=ntfy://ntfy.example.com/homelab-updates +WATCHTOWER_NOTIFICATIONS= +WATCHTOWER_NOTIFICATION_URL= +WATCHTOWER_NOTIFICATIONS_LEVEL=info +WATCHTOWER_NOTIFICATION_REPORT=true +WATCHTOWER_NOTIFICATION_LOG_STDOUT=false diff --git a/stacks/base/README.md b/stacks/base/README.md index 19505b80..0f277c84 100644 --- a/stacks/base/README.md +++ b/stacks/base/README.md @@ -1,76 +1,179 @@ # Base Infrastructure Stack -The foundation of HomeLab Stack. Must be deployed **before any other stack**. +The base stack must be running before the other service stacks. It provides the shared `proxy` network, public HTTPS entrypoint, Docker management UI, and label-scoped automatic updates. -## What's Included +## Services -| Service | Version | URL | Purpose | -|---------|---------|-----|---------| -| Traefik | 3.1 | `traefik.` | Reverse proxy + TLS termination | -| Portainer CE | 2.21 | `portainer.` | Docker management UI | -| Watchtower | latest-stable | — | Automatic container updates | +| Service | Image | URL | Purpose | +|---------|-------|-----|---------| +| Traefik | `traefik:v3.6.1` | `https://traefik.` | Reverse proxy, HTTPS redirect, TLS termination, dashboard | +| Portainer CE | `portainer/portainer-ce:2.21.3` | `https://portainer.` | Docker management UI | +| Watchtower | `containrrr/watchtower:1.7.1` | none | Daily label-scoped image updates | +| Docker Socket Proxy | `tecnativa/docker-socket-proxy:0.2.0` | internal only | Read-only Docker API isolation for Traefik | -## Architecture +## Network Model ``` Internet - │ - ▼ -[Traefik :443] - │ TLS termination (Let's Encrypt) - │ ForwardAuth → Authentik (optional) - │ - ├──► portainer. → Portainer - ├──► traefik. → Traefik Dashboard - └──► *.. → Other stacks via 'proxy' network - -[proxy] ← shared Docker network — all stacks attach here + | + v +Traefik :80/:443 ---> proxy network ---> public services with traefik.enable=true + | + v +socket-proxy network ---> docker-socket-proxy ---> /var/run/docker.sock ``` -## Prerequisites +- `proxy` is an external Docker network shared by every stack that Traefik routes. +- `homelab-socket-proxy` is an internal-only network used by Traefik to read Docker metadata. +- Traefik never mounts `/var/run/docker.sock`; it talks to `tcp://socket-proxy:2375` and the proxy exposes only read endpoints needed for container discovery. +- Portainer and Watchtower keep direct Docker socket access because they are Docker management/update tools. -- Docker >= 24.0 with Compose v2 plugin -- Ports 80 and 443 open on your firewall -- A domain pointing to your server's IP (A record) -- `./scripts/setup-env.sh` completed (creates `.env` and `acme.json`) +## DNS -## Quick Start +Create DNS records that point at the server running Docker: -```bash -# From repo root — recommended (runs check-deps + setup-env first) -./install.sh +| Record | Target | +|--------|--------| +| `traefik.` | server public IP | +| `portainer.` | server public IP | +| `*.` | server public IP, optional but useful for later stacks | + +For HTTP-01 certificates, ports `80/tcp` and `443/tcp` must reach the server from the public internet. For private or wildcard deployments, use the DNS challenge resolver described below. + +## Environment -# Or manually: +For standalone base-stack deployment: + +```bash cd stacks/base -ln -sf ../../.env .env # share root .env +cp .env.example .env +``` + +Required values: + +| Variable | Description | +|----------|-------------| +| `DOMAIN` | Base domain, for example `home.example.com` | +| `ACME_EMAIL` | Let's Encrypt account email | +| `TRAEFIK_AUTH` | `htpasswd` BasicAuth user/hash for the Traefik dashboard | +| `TZ` | Container timezone, for example `Asia/Shanghai` | + +Generate the dashboard credential: + +```bash +htpasswd -nbB admin 'change-this-password' | sed -e 's/\$/\$\$/g' +``` + +Paste the full `admin:...` output into `TRAEFIK_AUTH`. + +## Certificates + +The default resolver is `letsencrypt`, which uses HTTP-01 on port 80: + +```env +TRAEFIK_CERT_RESOLVER=letsencrypt +``` + +For DNS challenge, set the router resolver to `letsencryptdns` and provide the matching Lego provider credentials. The included example uses Cloudflare: + +```env +TRAEFIK_CERT_RESOLVER=letsencryptdns +ACME_DNS_PROVIDER=cloudflare +CF_DNS_API_TOKEN=your-cloudflare-dns-token +``` + +Certificates are stored in `config/traefik/acme.json`. Create it before the first start: + +```bash +touch ../../config/traefik/acme.json +chmod 600 ../../config/traefik/acme.json +``` + +## Start + +```bash +docker network create proxy 2>/dev/null || true docker compose up -d ``` -## Configuration +Expected containers: -### Environment Variables (`.env`) +- `docker-socket-proxy` +- `traefik` +- `portainer` +- `watchtower` -| Variable | Required | Description | -|----------|----------|-------------| -| `DOMAIN` | ✅ | Base domain, e.g. `home.example.com` | -| `ACME_EMAIL` | ✅ | Email for Let's Encrypt notifications | -| `TRAEFIK_DASHBOARD_USER` | ✅ | Dashboard login username | -| `TRAEFIK_DASHBOARD_PASSWORD_HASH` | ✅ | Bcrypt hash — see below | -| `TZ` | ✅ | Timezone, e.g. `Asia/Shanghai` | -| `CN_MODE` | — | `true` to use CN Docker mirrors | +Check status: -### Generate Dashboard Password Hash +```bash +docker compose ps +curl -I http://127.0.0.1 +curl -I https://traefik.${DOMAIN}/dashboard/ +curl -I https://portainer.${DOMAIN}/api/status +``` + +The plain HTTP request should redirect to HTTPS. The Traefik dashboard should require BasicAuth. Portainer should respond through Traefik and will ask for first-login setup in the UI. + +## Watchtower Notifications + +Watchtower runs at 03:00 daily and only updates containers labeled with: + +```yaml +com.centurylinklabs.watchtower.enable: "true" +``` + +Notification delivery uses Shoutrrr URLs. Set one of these after the Notifications stack is available or when using an external endpoint: + +```env +WATCHTOWER_NOTIFICATIONS=shoutrrr +WATCHTOWER_NOTIFICATION_URL=ntfy://ntfy.example.com/homelab-updates +# or +WATCHTOWER_NOTIFICATION_URL=gotify://gotify.example.com/token +WATCHTOWER_NOTIFICATIONS_LEVEL=info +WATCHTOWER_NOTIFICATION_REPORT=true +``` + +`WATCHTOWER_NOTIFICATION_REPORT=true` sends Watchtower's update-session report through the configured notification URL when containers are updated or fail to update. For local notification smoke tests without real Gotify/ntfy credentials, use `WATCHTOWER_NOTIFICATION_URL=logger://` with `WATCHTOWER_NOTIFICATION_LOG_STDOUT=true`. + +## Local Validation + +Traefik uses strict SNI, so `curl -H "Host: ..."` against `https://127.0.0.1` will fail unless the TLS certificate store has a certificate for that host. Use one of these local paths instead. + +For a quick provider/dashboard check without HTTPS or BasicAuth, use the local override: ```bash -# Install htpasswd (Debian/Ubuntu) -sudo apt-get install -y apache2-utils +TRAEFIK_DASHBOARD_PORT=18080 docker compose -f docker-compose.yml -f docker-compose.local.yml up -d +curl -sS http://127.0.0.1:18080/api/http/routers +``` -# Generate hash (replace 'yourpassword') -htpasswd -nbB admin 'yourpassword' | sed -e 's/\$$/\$\$\$/g' +For a production-router check without public DNS, add a temporary local certificate and use `--resolve` so SNI matches the router host: -# Paste output into .env as TRAEFIK_DASHBOARD_PASSWORD_HASH +```bash +openssl req -x509 -nodes -newkey rsa:2048 -days 1 \ + -keyout ../../config/traefik/dynamic/local-key.pem \ + -out ../../config/traefik/dynamic/local-cert.pem \ + -subj "/CN=${DOMAIN}" \ + -addext "subjectAltName=DNS:${DOMAIN},DNS:traefik.${DOMAIN},DNS:portainer.${DOMAIN}" + +cat > ../../config/traefik/dynamic/local.generated.yml <<'YAML' +tls: + certificates: + - certFile: /dynamic/local-cert.pem + keyFile: /dynamic/local-key.pem +YAML + +curl --noproxy '*' -k -I \ + --resolve "traefik.${DOMAIN}:443:127.0.0.1" \ + "https://traefik.${DOMAIN}/dashboard/" + +curl --noproxy '*' -k -I \ + --resolve "portainer.${DOMAIN}:443:127.0.0.1" \ + "https://portainer.${DOMAIN}/api/status" ``` -### TLS Certificates +## Troubleshooting -Traefik uses Let's Encrypt HTTP-01 challenge by default. Certificates are stored in \ No newline at end of file +- `network proxy declared as external, but could not be found`: run `docker network create proxy`. +- Traefik dashboard returns `401`: BasicAuth is working; authenticate with the username used in `TRAEFIK_AUTH`. +- Let's Encrypt errors on local domains are expected; use a real public DNS record or the DNS challenge resolver. +- If `docker-socket-proxy` is unhealthy, confirm Docker is available on the host and `/var/run/docker.sock` exists. diff --git a/stacks/base/docker-compose.local.yml b/stacks/base/docker-compose.local.yml index 4907a21e..04c598a3 100644 --- a/stacks/base/docker-compose.local.yml +++ b/stacks/base/docker-compose.local.yml @@ -3,9 +3,8 @@ services: traefik: volumes: - ../../config/traefik/traefik.local.yml:/traefik.yml:ro - - /var/run/docker.sock:/var/run/docker.sock:ro - ../../config/traefik/dynamic:/dynamic:ro - traefik-logs:/var/log/traefik ports: - "80:80" - - "8080:8080" + - "${TRAEFIK_DASHBOARD_PORT:-8080}:8080" diff --git a/stacks/base/docker-compose.yml b/stacks/base/docker-compose.yml index 7185e836..3234ef9a 100644 --- a/stacks/base/docker-compose.yml +++ b/stacks/base/docker-compose.yml @@ -1,36 +1,90 @@ # ============================================================================= # HomeLab Stack — Base Infrastructure # Services: Traefik (reverse proxy) + Portainer (container management) -# + Watchtower (auto-updates) +# + Watchtower (auto-updates) + Docker Socket Proxy # # Prerequisites: -# 1. cp .env.example .env && fill in required values -# 2. ./scripts/check-deps.sh +# 1. cp stacks/base/.env.example stacks/base/.env && fill in required values +# 2. ../../scripts/check-deps.sh # 3. docker network create proxy -# 4. touch config/traefik/acme.json && chmod 600 config/traefik/acme.json +# 4. touch ../../config/traefik/acme.json && chmod 600 ../../config/traefik/acme.json # 5. docker compose up -d # ============================================================================= services: + # --------------------------------------------------------------------------- + # Docker Socket Proxy — Read-only Docker API for Traefik discovery + # Keeps Traefik off the host Docker socket and exposes only the endpoints + # required by the Traefik Docker provider. + # --------------------------------------------------------------------------- + socket-proxy: + image: tecnativa/docker-socket-proxy:0.2.0 + container_name: docker-socket-proxy + restart: unless-stopped + networks: + - socket-proxy + environment: + - LOG_LEVEL=info + - PING=1 + - VERSION=1 + - INFO=1 + - EVENTS=1 + - CONTAINERS=1 + - NETWORKS=1 + - POST=0 + - BUILD=0 + - COMMIT=0 + - CONFIGS=0 + - DISTRIBUTION=0 + - EXEC=0 + - IMAGES=0 + - NODES=0 + - PLUGINS=0 + - SECRETS=0 + - SERVICES=0 + - SESSION=0 + - SWARM=0 + - SYSTEM=0 + - TASKS=0 + - VOLUMES=0 + volumes: + - /var/run/docker.sock:/var/run/docker.sock:ro + labels: + - "com.centurylinklabs.watchtower.enable=true" + healthcheck: + test: ["CMD-SHELL", "wget -q -O - http://127.0.0.1:2375/_ping | grep -q OK"] + interval: 30s + timeout: 10s + retries: 3 + start_period: 10s + # --------------------------------------------------------------------------- # Traefik — Reverse Proxy & TLS Termination # Dashboard: https://traefik.${DOMAIN} # Docs: https://doc.traefik.io/traefik/ # --------------------------------------------------------------------------- traefik: - image: traefik:v3.1.6 + image: traefik:v3.6.1 container_name: traefik restart: unless-stopped + depends_on: + socket-proxy: + condition: service_healthy networks: - proxy + - socket-proxy ports: - "80:80" - "443:443" environment: - - TZ=${TZ} + - TZ=${TZ:-Asia/Shanghai} + - TRAEFIK_CERTIFICATESRESOLVERS_LETSENCRYPT_ACME_EMAIL=${ACME_EMAIL} + - TRAEFIK_CERTIFICATESRESOLVERS_LETSENCRYPTDNS_ACME_EMAIL=${ACME_EMAIL} + - TRAEFIK_CERTIFICATESRESOLVERS_LETSENCRYPTDNS_ACME_DNSCHALLENGE_PROVIDER=${ACME_DNS_PROVIDER:-cloudflare} + - CF_DNS_API_TOKEN=${CF_DNS_API_TOKEN:-} + - DOCKER_API_VERSION=${DOCKER_API_VERSION:-1.43} volumes: - - /var/run/docker.sock:/var/run/docker.sock:ro - ../../config/traefik/traefik.yml:/traefik.yml:ro - ../../config/traefik/dynamic:/dynamic:ro - ../../config/traefik/acme.json:/acme.json @@ -41,10 +95,14 @@ services: # Router: HTTPS dashboard - "traefik.http.routers.traefik-dashboard.rule=Host(`traefik.${DOMAIN}`)" - "traefik.http.routers.traefik-dashboard.entrypoints=websecure" - - "traefik.http.routers.traefik-dashboard.tls.certresolver=letsencrypt" + - "traefik.http.routers.traefik-dashboard.tls=true" + - "traefik.http.routers.traefik-dashboard.tls.certresolver=${TRAEFIK_CERT_RESOLVER:-letsencrypt}" - "traefik.http.routers.traefik-dashboard.service=api@internal" # Protect dashboard with BasicAuth - - "traefik.http.routers.traefik-dashboard.middlewares=traefik-auth@file,security-headers@file" + - "traefik.http.middlewares.traefik-dashboard-auth.basicauth.users=${TRAEFIK_AUTH}" + - "traefik.http.middlewares.traefik-dashboard-auth.basicauth.removeheader=true" + - "traefik.http.routers.traefik-dashboard.middlewares=traefik-dashboard-auth,security-headers@file,rate-limit@file" + - "com.centurylinklabs.watchtower.enable=true" healthcheck: test: ["CMD", "traefik", "healthcheck", "--ping"] interval: 30s @@ -58,22 +116,24 @@ services: # First login: set admin password within 5 minutes # --------------------------------------------------------------------------- portainer: - image: portainer/portainer-ce:2.21.4 + image: portainer/portainer-ce:2.21.3 container_name: portainer restart: unless-stopped networks: - proxy volumes: - - /var/run/docker.sock:/var/run/docker.sock:ro + - /var/run/docker.sock:/var/run/docker.sock - portainer-data:/data labels: - "traefik.enable=true" - "traefik.http.routers.portainer.rule=Host(`portainer.${DOMAIN}`)" - "traefik.http.routers.portainer.entrypoints=websecure" - - "traefik.http.routers.portainer.tls.certresolver=letsencrypt" + - "traefik.http.routers.portainer.tls=true" + - "traefik.http.routers.portainer.tls.certresolver=${TRAEFIK_CERT_RESOLVER:-letsencrypt}" - "traefik.http.routers.portainer.service=portainer" - - "traefik.http.routers.portainer.middlewares=security-headers@file" + - "traefik.http.routers.portainer.middlewares=security-headers@file,rate-limit@file" - "traefik.http.services.portainer.loadbalancer.server.port=9000" + - "com.centurylinklabs.watchtower.enable=true" healthcheck: test: ["CMD", "/portainer", "--version"] interval: 30s @@ -83,8 +143,8 @@ services: # --------------------------------------------------------------------------- # Watchtower — Automatic Container Updates - # Checks for image updates daily at 4:00 AM - # Notifications sent via configured notifier (see .env) + # Checks for image updates daily at 3:00 AM. + # Notifications can use Shoutrrr URLs for ntfy or Gotify (see .env.example). # --------------------------------------------------------------------------- watchtower: image: containrrr/watchtower:1.7.1 @@ -93,15 +153,19 @@ services: networks: - proxy environment: - - TZ=${TZ} + - TZ=${TZ:-Asia/Shanghai} - WATCHTOWER_CLEANUP=true - WATCHTOWER_INCLUDE_RESTARTING=true - - WATCHTOWER_SCHEDULE=0 0 4 * * * + - WATCHTOWER_SCHEDULE=0 0 3 * * * - WATCHTOWER_TIMEOUT=60s - - WATCHTOWER_NOTIFICATIONS_LEVEL=warn + - WATCHTOWER_NOTIFICATIONS=${WATCHTOWER_NOTIFICATIONS:-} + - WATCHTOWER_NOTIFICATION_URL=${WATCHTOWER_NOTIFICATION_URL:-} + - WATCHTOWER_NOTIFICATIONS_LEVEL=${WATCHTOWER_NOTIFICATIONS_LEVEL:-info} + - WATCHTOWER_NOTIFICATION_REPORT=${WATCHTOWER_NOTIFICATION_REPORT:-true} + - WATCHTOWER_NOTIFICATION_LOG_STDOUT=${WATCHTOWER_NOTIFICATION_LOG_STDOUT:-false} # Scope: only update containers with this label - WATCHTOWER_LABEL_ENABLE=true - - DOCKER_API_VERSION=1.44 + - DOCKER_API_VERSION=${DOCKER_API_VERSION:-1.43} volumes: - /var/run/docker.sock:/var/run/docker.sock:ro labels: @@ -121,6 +185,10 @@ services: networks: proxy: external: true + name: proxy + socket-proxy: + name: homelab-socket-proxy + internal: true # ============================================================================= # Volumes