diff options
| -rw-r--r-- | .env.enc | 5 | ||||
| -rw-r--r-- | .gitignore | 2 | ||||
| -rw-r--r-- | .sops.yaml | 4 | ||||
| -rw-r--r-- | README.md | 24 | ||||
| -rw-r--r-- | caddy/Caddyfile | 9 | ||||
| -rwxr-xr-x | deploy.sh | 33 | ||||
| -rw-r--r-- | docker-compose.yml | 6 |
7 files changed, 75 insertions, 8 deletions
@@ -28,9 +28,10 @@ RADARR_API_KEY=ENC[AES256_GCM,data:DHtJK8BSfruEzCbPkSuio7/qiw137rB2UZzyVjzelAU=, SONARR_API_KEY=ENC[AES256_GCM,data:pz18U25UTap7JFLAj0I7SYrR+Y2HFxUhNgFSfkZsW8o=,iv:ps47Vp3/QoqYtfAt+U9ZS1/87K9+o/xBa/SX0ZqdwiA=,tag:P2oZIYB7WEOS7BWX/m/YBQ==,type:str] PROWLARR_API_KEY=ENC[AES256_GCM,data:b6GYF0USAHqlmezOQlQMO1VwTUjAn1GjgoRkgaSXd5o=,iv:bGgXTV1hqFOZOkPIzP63c1lsEixropX3T2BLqwmZYN4=,tag:tyYc+Rq+guWigoUghPbEgQ==,type:str] BAZARR_API_KEY=ENC[AES256_GCM,data:gr1guUQ17CUxuwPzDGtXegFPVA4CgIdJvWXAdxXf8nE=,iv:zX4faC5KE88E5GfG29rvFYd5FGAPJt6l+7wT6WQ5c7c=,tag:Bv/ZbW3IUiMnJFSChRpRfQ==,type:str] +LAN_IP=ENC[AES256_GCM,data:sbYMEnIuCIcTU5Ek,iv:d5NysqFShXysQR5PUlUESiY3Uw0ri6MKgK4wi3oAhX8=,tag:Z/6DNZevYFWE4cU7wP3pmw==,type:str] sops_age__list_0__map_enc=-----BEGIN AGE ENCRYPTED FILE-----\nYWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSB2WFE5QzNXNlhxakx3T3Yv\nV1FqVXBsQ21DdDB4R05hUGpqM09Qd0FsTno0CjJlL1lYNDNHVDBsRnRJNkdNcnJ0\nZnVVRFFWazcxUmV0emNTMzVLajNMdDQKLS0tIHBBWGRuWXdIWHYrcjJTeXVzLzZE\nVnpTeUNrQ205Tm1WODRLQkdablFaajAKM7W7VXTND6e33myo4rP/kFWQ938gFX1Y\nTRXBBCFMxJwRAjp9XE2IFsxxDHaHJnj/6O3k6kEyQsGZUwbWKfz7Pg==\n-----END AGE ENCRYPTED FILE-----\n sops_age__list_0__map_recipient=age1sv5yc279d8j66fx36awxhdnsqjp3pv8x9yc0ak6xa9qy9egg6e8qegccg4 -sops_lastmodified=2026-07-19T18:58:17Z -sops_mac=ENC[AES256_GCM,data:3SmAp/szkWnkZOTfyapqG/1LiBn52I6XVQrnYe8F6FeT1fGwgmEkp8iXOMq7mdzoDxd0lGrN/rs6URQNok05DMtAVCyZkz1eXI7VN7kpza6FHwTI520IwV0oEyzJA9995X3E0+c1TuL9c7okdiEi0QLXcmt4XvzegNPfRCV0J2g=,iv:A2E/fnzn0zcB18OPhrBsCazxbTiKtmOmqdWZ1+pVA/M=,tag:eAOrpmF/XIu10VkQ3K6HdQ==,type:str] +sops_lastmodified=2026-07-26T16:56:26Z +sops_mac=ENC[AES256_GCM,data:1OcJFIhZmpD37vzwyC6H9LaPFmHeSy4ky6L9mn2W+bxeS+efR30VYvdjjqd/ejoExTgoEtt+Iq2nDoLdC+bv9ZUcBcTsQu6IDcYQ1lc/KyFN1CYkWIYPgOhT2ePnMfKQcayedx1zmVaQlumdAeM1XNadDul2hTneontZtyUyCzs=,iv:/cJGM+Qmx9GK+VinC6gBq8O9EP5+UL87uVwoE4O0zrI=,tag:6gfNlqYTT5jpXPajvKeUaA==,type:str] sops_unencrypted_suffix=_unencrypted sops_version=3.13.2 @@ -1 +1,3 @@ .env +.env.* +!.env.enc @@ -1,3 +1,7 @@ creation_rules: - path_regex: \.env$ age: age1sv5yc279d8j66fx36awxhdnsqjp3pv8x9yc0ak6xa9qy9egg6e8qegccg4 + - path_regex: \.env\.enc$ + age: age1sv5yc279d8j66fx36awxhdnsqjp3pv8x9yc0ak6xa9qy9egg6e8qegccg4 + input_type: dotenv + output_type: dotenv @@ -1,12 +1,26 @@ # goodall -Docker Compose media stack (Jellyfin, Radarr, Sonarr, Prowlarr, Bazarr, qBittorrent) fronted by Caddy, reachable only over Tailscale. +Docker Compose media stack (Jellyfin, Radarr, Sonarr, Prowlarr, Bazarr, qBittorrent) fronted by Caddy, reachable only over Tailscale. qBittorrent's traffic is routed entirely through Gluetun (WireGuard to Mullvad) via `network_mode: "service:gluetun"`. -Deployed on the NAS at `/volume2/docker/goodall` (host `karan@100.121.200.111`, tailnet name `goodall.taildc6b0a.ts.net`). This local directory is the source of truth for edits; redeploy a changed file with: +Deployed on the NAS at `/volume2/docker/goodall` (host `karan@100.121.200.111`, tailnet name `goodall.taildc6b0a.ts.net`). This local directory is the source of truth for edits; ship a full deploy with: ``` -ssh karan@100.121.200.111 "cat > /volume2/docker/goodall/<file>" < ./<file> -ssh karan@100.121.200.111 "cd /volume2/docker/goodall && sudo docker compose up -d" +./deploy.sh ``` -See [MIGRATION_STATUS.md](MIGRATION_STATUS.md) for current deployment status and remaining work. +This clears the project directory on the NAS (preserving `config/` runtime state and `.env`), streams the repo over via tar+SSH (DSM's bundled rsync only supports its own daemon/module mode, not plain SSH+path, so this project uses tar instead), decrypts `.env.enc` and writes it directly to the NAS, then runs `docker compose pull && up -d` remotely. + +Note: this NAS's Docker Manager GUI doesn't show `goodall` as a project since it was deployed via SSH/CLI rather than the GUI's own wizard — cosmetic only, the stack runs fine either way. + +## Secrets + +All secrets and per-deployment config (VPN credentials, Gandi DNS token, LAN/Tailscale IPs, PUID/PGID/TZ, media paths, and the four app API keys) live encrypted in `.env.enc`, via sops. To view: `sops -d --input-type dotenv --output-type dotenv .env.enc`. To edit: `sops --input-type dotenv --output-type dotenv .env.enc` (opens `$EDITOR` on the decrypted content, re-encrypts on save). `deploy.sh` decrypts this on every run and writes it to the NAS directly — there's no plaintext `.env` to hand-maintain locally. + +## Updates + +All images (including Gluetun) track `:latest`. Nothing auto-updates — re-run `./deploy.sh` to pull and recreate everything. This is deliberate: `gluetun` and `qbittorrent` share a network namespace (`network_mode: "service:gluetun"`), so updates need to recreate both together, which a project-level `docker compose pull && up -d` does correctly and a per-container auto-updater would not. + +## Gotchas + +- **Gluetun blocks inbound container-to-container traffic by default** — `FIREWALL_INPUT_PORTS=8123` in `docker-compose.yml` is required, or qBittorrent's WebUI (proxied by Caddy for `dl.karanj.com`) returns a 502. +- **Caddy's Gandi DNS-01 challenge needs `propagation_timeout -1`** in the `tls_gandi` snippet (`caddy/Caddyfile`) — Gandi's own nameserver propagation is inconsistent across resolvers, so Caddy's own pre-check needs to be skipped and left to Let's Encrypt to validate directly. diff --git a/caddy/Caddyfile b/caddy/Caddyfile index 9d941d0..4f38033 100644 --- a/caddy/Caddyfile +++ b/caddy/Caddyfile @@ -38,3 +38,12 @@ dl.{$DOMAIN} { import tls_gandi reverse_proxy gluetun:8123 } + +nas.{$DOMAIN} { + import tls_gandi + reverse_proxy https://host.docker.internal:9443 { + transport http { + tls_insecure_skip_verify + } + } +} diff --git a/deploy.sh b/deploy.sh new file mode 100755 index 0000000..9c547cf --- /dev/null +++ b/deploy.sh @@ -0,0 +1,33 @@ +#!/usr/bin/env bash +set -euo pipefail + +NAS_HOST="karan@100.121.200.111" +NAS_PATH="/volume2/docker/goodall" +REPO_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +cd "$REPO_DIR" + +echo "==> Checking SSH connectivity to ${NAS_HOST}" +if ! ssh -o ConnectTimeout=5 -o BatchMode=yes "$NAS_HOST" true; then + echo "ERROR: cannot reach ${NAS_HOST} over SSH (tailscale down?)" >&2 + exit 1 +fi + +echo "==> Clearing stale project files on ${NAS_HOST}:${NAS_PATH} (preserving config/ and .env)" +ssh "$NAS_HOST" "mkdir -p ${NAS_PATH} && find ${NAS_PATH} -mindepth 1 -maxdepth 1 ! -name 'config' ! -name '.env' -exec rm -rf {} +" + +echo "==> Syncing project files to ${NAS_HOST}:${NAS_PATH}" +COPYFILE_DISABLE=1 tar -cz \ + --exclude='.git' \ + --exclude='.claude' \ + --exclude='config' \ + --exclude='.env' \ + . | ssh "$NAS_HOST" "tar -xzf - -C ${NAS_PATH}" + +echo "==> Decrypting secrets and writing remote .env" +sops --input-type dotenv --output-type dotenv -d .env.enc \ + | ssh "$NAS_HOST" "cat > ${NAS_PATH}/.env && chmod 600 ${NAS_PATH}/.env" + +echo "==> Pulling latest images and recreating containers" +ssh "$NAS_HOST" "cd ${NAS_PATH} && docker compose pull && docker compose up -d" + +echo "==> Deploy complete." diff --git a/docker-compose.yml b/docker-compose.yml index f93b205..6277bdd 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -1,6 +1,6 @@ services: gluetun: - image: qmcgaw/gluetun:v3.40.0 + image: qmcgaw/gluetun:latest container_name: gluetun cap_add: - NET_ADMIN @@ -49,6 +49,8 @@ services: - ${TV_PATH}:/shows devices: - /dev/dri:/dev/dri + ports: + - "${LAN_IP}:8096:8096" networks: - media restart: unless-stopped @@ -123,6 +125,8 @@ services: - ./caddy/Caddyfile:/etc/caddy/Caddyfile:ro - caddy_data:/data - caddy_config:/config + extra_hosts: + - "host.docker.internal:host-gateway" networks: - media depends_on: |
