diff --git a/.env.example b/.env.example index 7e58a08..32cfc1b 100644 --- a/.env.example +++ b/.env.example @@ -34,6 +34,34 @@ SEARXNG_PORT=8888 QBITTORRENT_PORT=8080 FLARESOLVERR_PORT=8191 CHANGEDETECTION_PORT=5000 +BROWSER_UI_PORT=3000 +BROWSER_CDP_PORT=9223 +METAMCP_PORT=12008 + +# ============================================================================== +# DAILY_STARS (optional — GitHub stars/trends explorer) +# ============================================================================== +DAILY_STARS_PORT=8080 + +# ============================================================================== +# METAMCP (optional — MCP gateway + bundled Postgres) +# ============================================================================== +# Session-signing secret for MetaMCP's auth. Required — the container refuses +# to start without it. +# Generate one with: openssl rand -hex 32 +BETTER_AUTH_SECRET=replace_this_with_your_random_openssl_generated_hex_secret_key + +# Bundled Postgres credentials. Required — the container refuses to start +# without a password. +# Generate one with: openssl rand -hex 32 +METAMCP_POSTGRES_PASSWORD=replace_this_with_your_random_openssl_generated_hex_secret_key +METAMCP_POSTGRES_USER=metamcp_user +METAMCP_POSTGRES_DB=metamcp_db +METAMCP_POSTGRES_HOST=metamcp-postgres +METAMCP_POSTGRES_PORT=5432 + +# Public URL MetaMCP reports itself as; update if reverse-proxied. +METAMCP_APP_URL=http://localhost:12008 # ============================================================================== # LOCAL STORAGE VOLUMES diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index f69b809..5a78f17 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -18,7 +18,10 @@ jobs: run: | # Create a dummy .env file based on the example to allow configuration validation cp .env.example .env - # Set a mock SearXNG secret to satisfy substitution checks + # Fill in the placeholder secret. This also covers BETTER_AUTH_SECRET and + # METAMCP_POSTGRES_PASSWORD in .env.example, which share the same + # placeholder text and are otherwise required (":?must be set") by + # metamcp/docker-compose.yml. sed -i 's/replace_this_with_your_random_openssl_generated_hex_secret_key/d3b07384d113edec49eaa6238ad5ff00/g' .env - name: Validate Root and Module Syntax @@ -26,10 +29,15 @@ jobs: # 1. Test root compose file with all default active includes docker compose config > /dev/null - # 2. Test every individual sub-module compose file independently + # 2. Test every individual sub-module compose file independently. + # --env-file is required here: `docker compose -f /x.yml` without + # it resolves the default .env relative to the compose file's own + # directory, not the repo root, so top-level ${VAR} interpolation + # (including required ":?" vars like metamcp's BETTER_AUTH_SECRET) + # silently falls back to unset/blank instead of reading ./.env. for compose_file in $(find . -maxdepth 2 -name "docker-compose.yml"); do echo "Validating: $compose_file" - docker compose -f "$compose_file" config > /dev/null + docker compose --env-file ./.env -f "$compose_file" config > /dev/null done security-scan: diff --git a/.github/workflows/smoke-test.yml b/.github/workflows/smoke-test.yml index 7d3fcb9..aaf3690 100644 --- a/.github/workflows/smoke-test.yml +++ b/.github/workflows/smoke-test.yml @@ -32,15 +32,21 @@ jobs: - name: Seed environment run: | cp .env.example .env + # Also covers BETTER_AUTH_SECRET and METAMCP_POSTGRES_PASSWORD, which + # share this placeholder in .env.example (metamcp isn't in this matrix, + # but the compose file is still parsed by `docker compose config` calls + # elsewhere, so it must resolve). sed -i 's/replace_this_with_your_random_openssl_generated_hex_secret_key/d3b07384d113edec49eaa6238ad5ff00/g' .env - # metamcp needs a real secret; harmless for the others. - echo "BETTER_AUTH_SECRET=$(openssl rand -hex 32)" >> .env if [ -f "${{ matrix.service }}/.env.example" ]; then cp "${{ matrix.service }}/.env.example" "${{ matrix.service }}/.env" fi - name: Start the stack - run: docker compose -f "${{ matrix.service }}/docker-compose.yml" up -d + # --env-file is required: `docker compose -f /x.yml` alone resolves + # the default .env relative to the compose file's directory, not the repo + # root, so top-level ${VAR} interpolation (e.g. SEARXNG_SECRET) silently + # falls back to blank instead of reading the seeded ./.env. See ci.yml. + run: docker compose --env-file ./.env -f "${{ matrix.service }}/docker-compose.yml" up -d - name: Wait for containers to settle run: sleep 20 @@ -52,16 +58,16 @@ jobs: # started and then crashed simply vanishes from the output and the check # passes — the exact failure this step exists to catch. See # docs/HARD-WON-GOTCHAS.md, "Docker operations". - not_running=$(docker compose -f "${{ matrix.service }}/docker-compose.yml" \ + not_running=$(docker compose --env-file ./.env -f "${{ matrix.service }}/docker-compose.yml" \ ps -a --format '{{.Name}} {{.State}}' | grep -v ' running$' || true) if [ -n "$not_running" ]; then echo "::error::These containers are not running:" echo "$not_running" - docker compose -f "${{ matrix.service }}/docker-compose.yml" logs + docker compose --env-file ./.env -f "${{ matrix.service }}/docker-compose.yml" logs exit 1 fi echo "✅ all containers running" - name: Tear down if: always() - run: docker compose -f "${{ matrix.service }}/docker-compose.yml" down -v + run: docker compose --env-file ./.env -f "${{ matrix.service }}/docker-compose.yml" down -v diff --git a/AGENTS.md b/AGENTS.md index d55821a..ab411e4 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -125,6 +125,27 @@ ports: - "127.0.0.1:${SEARXNG_PORT:-8888}:${SEARXNG_PORT:-8888}" ``` +### 6. Capability Hardening 🔒 +Every service **must** drop all Linux capabilities and add back only what its actual +entrypoint needs — never leave a container on the Docker default capability set. +Read the entrypoint (`docker run --rm --entrypoint cat `) +before guessing. Two patterns cover almost everything in this repo (see +`docs/HARD-WON-GOTCHAS.md`, "Container capabilities"): +```yaml +# s6-overlay / PUID-PGID images (LinuxServer-style, Postgres) that start as root, +# chown their data, then drop to a service user: +cap_drop: [ALL] +cap_add: [CHOWN, FOWNER, DAC_OVERRIDE, SETUID, SETGID] +security_opt: [no-new-privileges:true] + +# Images that already run as a non-root user (no privilege-drop dance needed): +cap_drop: [ALL] +security_opt: [no-new-privileges:true] +``` +For a Chromium-based image, do not assume it needs `SYS_ADMIN` or +`seccomp=unconfined` — test the actual image against a real CDP call first; several +in this repo needed neither. + --- ## 🔌 Service Configurations Reference diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..886f8b3 --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 Jonathan Dumont + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/README.md b/README.md index 7a5bd4e..d238b04 100644 --- a/README.md +++ b/README.md @@ -23,7 +23,21 @@ This repository contains a highly optimized, production-grade, and resilient mul This compose stack is built with the highest standards of production container orchestration: -### 1. 🌲 Hierarchical Environment Overrides +### 1. 📌 Pinned Image Versions +Every image is pinned to a specific version tag (never `:latest`), so upgrades are +explicit and reviewable rather than silent. [Renovate](https://docs.renovatebot.com/) +runs weekly (`.github/workflows/check-upstream-release.yml`) to open version-bump PRs +as upstream releases land. + +### 2. 🔒 Container Hardening +Every container drops all Linux capabilities by default (`cap_drop: [ALL]`) and adds +back only what its entrypoint actually needs — the s6-overlay/PUID-PGID root→user +privilege-drop set for LinuxServer-style images, or nothing at all for images that +already run as non-root. `security_opt: [no-new-privileges:true]` is set throughout. +See [`docs/HARD-WON-GOTCHAS.md`](docs/HARD-WON-GOTCHAS.md) for the reasoning and the +traps this avoids (a bare `cap_drop: ALL` breaks the root→user drop dance). + +### 3. 🌲 Hierarchical Environment Overrides All service definitions support a modern, 4-layered environment file hierarchy. Variables are evaluated sequentially (later files override/supersede earlier ones), allowing host-specific overrides to be kept strictly separate from the base configurations: ```yaml env_file: @@ -37,18 +51,18 @@ All service definitions support a modern, 4-layered environment file hierarchy. required: false ``` -### 2. 🛡️ 100% Secure & Public Ready +### 4. 🛡️ 100% Secure & Public Ready * **Zero Hardcoded Secrets**: Cryptographic keys like `SEARXNG_SECRET` are passed dynamically from `.env` using environment variables. No secrets are stored in `settings.yml`. * **Privacy-Friendly Directory Mounts**: Your physical host storage directories (e.g. `/mnt/...`) are kept in your local `.env` and are strictly excluded from version control via `.gitignore`. * **Clean `.env.example`**: A fully commented template is provided for a seamless open-source setup experience. -### 3. 📉 Resource Boundaries +### 5. 📉 Resource Boundaries Every container is capped with memory and CPU boundaries using Docker's `deploy.resources.limits` configuration to prevent memory leaks or background loop bugs from freezing your host system. -### 4. 🪵 Log Rotations +### 6. 🪵 Log Rotations To protect your host disk from filling up, all containers are constrained to standard JSON file logging rotations (`max-size: "10m"`, `max-file: "3"`). -### 5. 🧟 Zombie Process Reaping +### 7. 🧟 Zombie Process Reaping Containers running headless Chromium instances (`browser-sockpuppet-chrome` and `flaresolverr`) are configured with `init: true`. This invokes the lightweight Docker init-system to automatically reap zombie child processes. --- @@ -66,6 +80,7 @@ Containers running headless Chromium instances (`browser-sockpuppet-chrome` and ```bash openssl rand -hex 32 ``` +5. If you're enabling the optional MetaMCP service, generate its two required secrets the same way and set them under `BETTER_AUTH_SECRET` and `METAMCP_POSTGRES_PASSWORD` — both fail the container on startup if left as the placeholder. ### Running the Services Bring up the entire stack in the background: @@ -89,3 +104,13 @@ docker compose ps | **ChangeDetection** | `5000` | `5000` | `CHANGEDETECTION_PORT` | | **Flaresolverr** | `8191` | `8191` | `FLARESOLVERR_PORT` | | **Jellyfin** | *Host Network* | `8096` | *Managed via Host Net* | +| **Browser** (web UI) | `3000` | `3000` | `BROWSER_UI_PORT` | +| **Browser** (CDP) | `9223` | `9223` | `BROWSER_CDP_PORT` | +| **MetaMCP** | `12008` | `12008` | `METAMCP_PORT` | +| **Daily Stars Explorer** | `8080` | `8080` | `DAILY_STARS_PORT` | + +--- + +## 📜 License + +MIT — see [`LICENSE`](LICENSE). diff --git a/changedetection/docker-compose.yml b/changedetection/docker-compose.yml index 271f893..65cf216 100644 --- a/changedetection/docker-compose.yml +++ b/changedetection/docker-compose.yml @@ -1,7 +1,7 @@ # https://github.com/linuxserver/docker-changedetection.io services: changedetection: - image: lscr.io/linuxserver/changedetection.io:latest + image: lscr.io/linuxserver/changedetection.io:0.60.6 container_name: changedetection restart: unless-stopped ports: @@ -22,6 +22,18 @@ services: - PGID=${PGID:-100} - TZ=${TZ:-Europe/Paris} - PLAYWRIGHT_DRIVER_URL=ws://browser-sockpuppet-chrome:3000 + # s6-overlay root->PUID/PGID drop dance — same set as jellyfin/qbittorrent/ + # postgres in this repo (docs/HARD-WON-GOTCHAS.md). + cap_drop: + - ALL + cap_add: + - CHOWN + - FOWNER + - DAC_OVERRIDE + - SETUID + - SETGID + security_opt: + - no-new-privileges:true healthcheck: test: - CMD @@ -46,12 +58,19 @@ services: browser-sockpuppet-chrome: hostname: browser-sockpuppet-chrome - image: dgtlmoon/sockpuppetbrowser:latest + image: dgtlmoon/sockpuppetbrowser:0.0.3 container_name: browser-sockpuppet-chrome restart: unless-stopped init: true + # Already runs as non-root "chrome" — no privilege-drop dance needed, but + # scope capabilities to just what Chrome's sandbox needs instead of the + # full default set (see docs/HARD-WON-GOTCHAS.md, "Chromium capability advice"). + cap_drop: + - ALL cap_add: - SYS_ADMIN + security_opt: + - no-new-privileges:true env_file: - path: ../.env required: false diff --git a/daily-stars-explorer/docker-compose.yml b/daily-stars-explorer/docker-compose.yml new file mode 100644 index 0000000..c31889b --- /dev/null +++ b/daily-stars-explorer/docker-compose.yml @@ -0,0 +1,48 @@ +services: + daily-stars-explorer: + image: 'ghcr.io/emanuelef/daily-stars-explorer:20260913-1246' + container_name: daily-stars-explorer + restart: unless-stopped + # Runs as root with no privilege-drop dance (plain Go binary entrypoint, no + # s6-overlay/PUID-PGID pattern — see Dockerfile upstream). Live-tested with + # zero extra capabilities; don't add any without re-testing (docs/HARD-WON-GOTCHAS.md). + cap_drop: + - ALL + security_opt: + - no-new-privileges:true + ports: + - "127.0.0.1:${DAILY_STARS_PORT:-8080}:8080" + env_file: + - path: ../.env + required: false + - path: ../.env.local + required: false + - path: .env + required: false + - path: .env.local + required: false + environment: + - TZ=${TZ:-Europe/Paris} + - HOST=0.0.0.0 + - PORT=8080 + healthcheck: + test: + - CMD + - wget + - '-q' + - '--spider' + - 'http://127.0.0.1:8080/health' + interval: 10s + timeout: 5s + retries: 3 + start_period: 15s + deploy: + resources: + limits: + cpus: '1.0' + memory: 512M + logging: + driver: "json-file" + options: + max-size: "10m" + max-file: "3" diff --git a/flaresolverr/docker-compose.yml b/flaresolverr/docker-compose.yml index f2d1fff..200aac1 100644 --- a/flaresolverr/docker-compose.yml +++ b/flaresolverr/docker-compose.yml @@ -1,9 +1,16 @@ services: flaresolverr: - image: 'ghcr.io/flaresolverr/flaresolverr:latest' + image: 'ghcr.io/flaresolverr/flaresolverr:v3.5.2' container_name: flaresolverr restart: unless-stopped init: true + # Already runs as non-root "flaresolverr"; its bundled Chromium runs with + # --no-sandbox, so no extra caps are needed (see docs/HARD-WON-GOTCHAS.md, + # "Chromium capability advice" — test the actual image, don't assume SYS_ADMIN). + cap_drop: + - ALL + security_opt: + - no-new-privileges:true ports: - "127.0.0.1:${FLARESOLVERR_PORT:-8191}:8191" env_file: diff --git a/jellyfin/docker-compose.yml b/jellyfin/docker-compose.yml index ffdc0f3..054c84d 100644 --- a/jellyfin/docker-compose.yml +++ b/jellyfin/docker-compose.yml @@ -1,7 +1,7 @@ --- services: jellyfin: - image: lscr.io/linuxserver/jellyfin:latest + image: lscr.io/linuxserver/jellyfin:12.1ubu2604-ls49 container_name: jellyfin restart: unless-stopped network_mode: host @@ -28,6 +28,18 @@ services: - PGID=${PGID:-100} - TZ=${TZ:-Europe/Paris} - DOCKER_MODS=linuxserver/mods:jellyfin-amd + # s6-overlay root->PUID/PGID drop dance — same set as qbittorrent/changedetection/ + # postgres in this repo (docs/HARD-WON-GOTCHAS.md). + cap_drop: + - ALL + cap_add: + - CHOWN + - FOWNER + - DAC_OVERRIDE + - SETUID + - SETGID + security_opt: + - no-new-privileges:true healthcheck: test: ["CMD", "curl", "-f", "http://localhost:8096/health"] interval: 15s diff --git a/metamcp/docker-compose.yml b/metamcp/docker-compose.yml index 5464e7a..3d48466 100644 --- a/metamcp/docker-compose.yml +++ b/metamcp/docker-compose.yml @@ -17,9 +17,9 @@ services: POSTGRES_HOST: ${METAMCP_POSTGRES_HOST:-metamcp-postgres} POSTGRES_PORT: ${METAMCP_POSTGRES_PORT:-5432} POSTGRES_USER: ${METAMCP_POSTGRES_USER:-metamcp_user} - POSTGRES_PASSWORD: ${METAMCP_POSTGRES_PASSWORD:-m3t4mcp} + POSTGRES_PASSWORD: "${METAMCP_POSTGRES_PASSWORD:?must be set — generate with openssl rand -hex 32}" POSTGRES_DB: ${METAMCP_POSTGRES_DB:-metamcp_db} - DATABASE_URL: postgresql://${METAMCP_POSTGRES_USER:-metamcp_user}:${METAMCP_POSTGRES_PASSWORD:-m3t4mcp}@${METAMCP_POSTGRES_HOST:-metamcp-postgres}:${METAMCP_POSTGRES_PORT:-5432}/${METAMCP_POSTGRES_DB:-metamcp_db} + DATABASE_URL: postgresql://${METAMCP_POSTGRES_USER:-metamcp_user}:${METAMCP_POSTGRES_PASSWORD:?must be set — generate with openssl rand -hex 32}@${METAMCP_POSTGRES_HOST:-metamcp-postgres}:${METAMCP_POSTGRES_PORT:-5432}/${METAMCP_POSTGRES_DB:-metamcp_db} APP_URL: ${METAMCP_APP_URL:-http://localhost:12008} NEXT_PUBLIC_APP_URL: ${METAMCP_APP_URL:-http://localhost:12008} BETTER_AUTH_SECRET: "${BETTER_AUTH_SECRET:?must be set — generate with openssl rand -hex 32}" @@ -60,7 +60,7 @@ services: environment: POSTGRES_DB: ${METAMCP_POSTGRES_DB:-metamcp_db} POSTGRES_USER: ${METAMCP_POSTGRES_USER:-metamcp_user} - POSTGRES_PASSWORD: ${METAMCP_POSTGRES_PASSWORD:-m3t4mcp} + POSTGRES_PASSWORD: "${METAMCP_POSTGRES_PASSWORD:?must be set — generate with openssl rand -hex 32}" volumes: # Postgres 18+ wants a SINGLE mount at /var/lib/postgresql, NOT the older # /var/lib/postgresql/data — it manages the version subdirectory itself. diff --git a/qbittorrent/docker-compose.yml b/qbittorrent/docker-compose.yml index 8041447..0b98e24 100644 --- a/qbittorrent/docker-compose.yml +++ b/qbittorrent/docker-compose.yml @@ -1,7 +1,7 @@ --- services: qbittorrent: - image: lscr.io/linuxserver/qbittorrent:latest + image: lscr.io/linuxserver/qbittorrent:5.2.3-libtorrentv1 container_name: qbittorrent restart: unless-stopped stop_grace_period: "15s" @@ -29,6 +29,18 @@ services: - TZ=${TZ:-Europe/Paris} - WEBUI_PORT=${WEBUI_PORT:-8080} - TORRENTING_PORT=${TORRENTING_PORT:-6881} + # s6-overlay root->PUID/PGID drop dance — same set as jellyfin/changedetection/ + # postgres in this repo (docs/HARD-WON-GOTCHAS.md). + cap_drop: + - ALL + cap_add: + - CHOWN + - FOWNER + - DAC_OVERRIDE + - SETUID + - SETGID + security_opt: + - no-new-privileges:true healthcheck: test: - CMD diff --git a/searxng/docker-compose.yml b/searxng/docker-compose.yml index 19dc59f..8f5f48d 100644 --- a/searxng/docker-compose.yml +++ b/searxng/docker-compose.yml @@ -1,7 +1,7 @@ # https://docs.searxng.org/admin/engines/settings.html services: searxng: - image: searxng/searxng:latest + image: searxng/searxng:2026.9.15-ca4965040 container_name: searxng restart: unless-stopped ports: @@ -25,6 +25,19 @@ services: depends_on: valkey: condition: service_healthy + # Same s6/root-entrypoint privilege-drop set as the other hardened services + # in this repo (see docs/HARD-WON-GOTCHAS.md) — the image stays root for + # update-ca-certificates and writing ./settings.yml/./limiter.toml on boot. + cap_drop: + - ALL + cap_add: + - CHOWN + - FOWNER + - DAC_OVERRIDE + - SETUID + - SETGID + security_opt: + - no-new-privileges:true healthcheck: test: - CMD @@ -54,6 +67,16 @@ services: restart: unless-stopped volumes: - valkey-data:/data + # Entrypoint chowns ./data then setpriv's from root to the valkey user — needs + # the same CHOWN/SETUID/SETGID as Postgres's drop dance (docs/HARD-WON-GOTCHAS.md). + cap_drop: + - ALL + cap_add: + - CHOWN + - SETUID + - SETGID + security_opt: + - no-new-privileges:true env_file: - path: ../.env required: false