Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
28 changes: 28 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
14 changes: 11 additions & 3 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -18,18 +18,26 @@ 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
run: |
# 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 <subdir>/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:
Expand Down
18 changes: 12 additions & 6 deletions .github/workflows/smoke-test.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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 <subdir>/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
Expand All @@ -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
21 changes: 21 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 <image> <path-to-entrypoint>`)
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
Expand Down
21 changes: 21 additions & 0 deletions LICENSE
Original file line number Diff line number Diff line change
@@ -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.
35 changes: 30 additions & 5 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand All @@ -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.

---
Expand All @@ -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:
Expand All @@ -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).
23 changes: 21 additions & 2 deletions changedetection/docker-compose.yml
Original file line number Diff line number Diff line change
@@ -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:
Expand All @@ -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
Expand All @@ -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
Expand Down
48 changes: 48 additions & 0 deletions daily-stars-explorer/docker-compose.yml
Original file line number Diff line number Diff line change
@@ -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"
9 changes: 8 additions & 1 deletion flaresolverr/docker-compose.yml
Original file line number Diff line number Diff line change
@@ -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:
Expand Down
14 changes: 13 additions & 1 deletion jellyfin/docker-compose.yml
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -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
Expand Down
6 changes: 3 additions & 3 deletions metamcp/docker-compose.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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}"
Expand Down Expand Up @@ -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.
Expand Down
Loading
Loading