selfhostedops-stack/compose.yml
Maximilian Grimm 1655d04d7d Episode 16: Kuma alerts sorted by rules, a local agent and a script
ops-readonly gets kuma_alerts (host export kuma-export.py, one more read-only
mount). kuma-triage.py sorts the same data without a model. Flow H runs the
agent with skill alert-triage.md. Flow B: one open incident per monitor, an
UP closes all of them, titles name start and end and stay under Outline's
limit. B and D accept Kuma only with a header token. Monitoring notes: ntfy
rejects Kuma messages for monitors without a URL, resend for long outages.
2026-09-29 22:14:57 +02:00

413 lines
16 KiB
YAML

# ~$ selfhostedops — The €15/Month Self-Hosted Company Stack
# Series A · Episode 1 — https://youtube.com/@selfhostedops
#
# One server. One file. Your whole company stack:
# caddy reverse proxy + automatic HTTPS
# forgejo git hosting + CI (was the SSO/OIDC provider until episode 15)
# outline team wiki (logs in via Pocket ID)
# pocket-id identity provider, passkeys + groups (episode 15)
# tinyauth login gate for services without SSO (episode 15)
# vaultwarden password manager (Bitwarden-compatible)
# uptime-kuma monitoring + alerts
# ntfy push notifications for those alerts (episode 6)
# n8n automation hub
# wg-easy WireGuard VPN (optional — start with: --profile vpn)
# (the full NetBird mesh is in netbird/ — episode 5)
#
# Quickstart:
# 1. cp .env.example .env && edit it (domain, email, secrets)
# 2. Point *.${DOMAIN} (wildcard A/AAAA record) at this server
# 3. docker compose up -d
#
# Every image below is multi-arch (amd64 + arm64) — runs on a €14 ARM box.
name: selfhostedops-stack
networks:
proxy:
# Pinned on purpose. Compose hands the subnets out in creation order, so a
# restore onto a fresh box can renumber them - and every NetBird route that
# points at the old range stops working, silently (episode 8).
ipam:
config:
- subnet: 172.18.0.0/16
driver_opts:
# Docker 28+ firewalls direct access to container IPs from other
# interfaces (raw PREROUTING drop per container). This lets the NetBird
# mesh interface reach the stack containers - see netbird/ (episode 5).
com.docker.network.bridge.trusted_host_interfaces: "wt0"
ci:
internal:
ipam:
config:
- subnet: 172.19.0.0/16
volumes:
caddy_data:
caddy_config:
forgejo_data:
outline_data:
outline_pg:
vaultwarden_data:
kuma_data:
ntfy_data:
ntfy_cache:
n8n_data:
pocket_id_data:
tinyauth_data:
wg_data:
docker_certs:
dind_data:
runner_data:
services:
caddy:
image: selfhostedops/caddy:crowdsec # episode 12: caddy-ratelimit + CrowdSec bouncer + AppSec
build: ./caddy
restart: unless-stopped
ports:
- "80:80"
- "443:443"
- "443:443/udp" # HTTP/3
environment:
DOMAIN: ${DOMAIN}
ACME_EMAIL: ${ACME_EMAIL}
CROWDSEC_BOUNCER_KEY: ${CROWDSEC_BOUNCER_KEY} # episode 12, see crowdsec/README.md
extra_hosts:
- "host.docker.internal:host-gateway" # episode 12: CrowdSec runs on the host
volumes:
- ./Caddyfile:/etc/caddy/Caddyfile:ro
- ./logs/caddy:/var/log/caddy # episode 10: JSON access log on the host for fail2ban
- caddy_data:/data
- caddy_config:/config
networks: [proxy]
forgejo:
image: codeberg.org/forgejo/forgejo:16
restart: unless-stopped
environment:
USER_UID: "1000"
USER_GID: "1000"
FORGEJO__server__DOMAIN: git.${DOMAIN}
FORGEJO__server__ROOT_URL: https://git.${DOMAIN}/
FORGEJO__server__SSH_PORT: "2222"
FORGEJO__service__DISABLE_REGISTRATION: "true"
FORGEJO__openid__ENABLE_OPENID_SIGNIN: "false"
FORGEJO__actions__ENABLED: "true"
FORGEJO__repository__DEFAULT_REPO_UNITS: "repo.code,repo.issues,repo.pulls,repo.releases,repo.packages,repo.actions"
# Forgejo refuses to deliver a webhook to a private address by default.
# Calling the automation hub over the docker network is exactly that, so
# the container name has to be listed. "external" keeps ordinary public
# webhook targets working (episode 8).
FORGEJO__webhook__ALLOWED_HOST_LIST: "external,n8n"
# Episode 15: people log in with Pocket ID (auth source "PocketID", added
# once with: forgejo admin auth add-oauth ...). An SSO login is linked to
# the existing account with the same mail address instead of a duplicate.
FORGEJO__oauth2_client__ENABLE_AUTO_REGISTRATION: "true"
FORGEJO__oauth2_client__ACCOUNT_LINKING: auto
FORGEJO__oauth2_client__USERNAME: nickname
ports:
- "2222:22" # git over SSH
volumes:
- forgejo_data:/data
networks: [proxy]
outline-postgres:
image: postgres:16-alpine
restart: unless-stopped
environment:
POSTGRES_USER: outline
POSTGRES_PASSWORD: ${OUTLINE_PG_PASSWORD}
POSTGRES_DB: outline
volumes:
- outline_pg:/var/lib/postgresql/data
networks: [internal]
outline-redis:
image: redis:7-alpine
restart: unless-stopped
networks: [internal]
outline:
image: outlinewiki/outline:latest
restart: unless-stopped
depends_on: [outline-postgres, outline-redis]
environment:
URL: https://wiki.${DOMAIN}
PORT: "3000"
SECRET_KEY: ${OUTLINE_SECRET_KEY}
UTILS_SECRET: ${OUTLINE_UTILS_SECRET}
DATABASE_URL: postgres://outline:${OUTLINE_PG_PASSWORD}@outline-postgres:5432/outline
PGSSLMODE: disable
REDIS_URL: redis://outline-redis:6379
FILE_STORAGE: local
FILE_STORAGE_LOCAL_ROOT_DIR: /var/lib/outline/data
FORCE_HTTPS: "false" # TLS terminates at caddy
# --- SSO via Pocket ID (episode 15; Forgejo was the provider until then) ---
OIDC_CLIENT_ID: ${OUTLINE_OIDC_CLIENT_ID}
OIDC_CLIENT_SECRET: ${OUTLINE_OIDC_CLIENT_SECRET}
OIDC_AUTH_URI: https://id.${DOMAIN}/authorize
OIDC_TOKEN_URI: https://id.${DOMAIN}/api/oidc/token
OIDC_USERINFO_URI: https://id.${DOMAIN}/api/oidc/userinfo
OIDC_LOGOUT_URI: https://id.${DOMAIN}/api/oidc/end-session
OIDC_DISPLAY_NAME: Pocket ID
OIDC_SCOPES: openid profile email
volumes:
- outline_data:/var/lib/outline/data
networks: [proxy, internal]
vaultwarden:
image: vaultwarden/server:latest
restart: unless-stopped
environment:
DOMAIN: https://vault.${DOMAIN}
ADMIN_TOKEN: ${VAULTWARDEN_ADMIN_TOKEN}
SIGNUPS_ALLOWED: "${VAULTWARDEN_SIGNUPS_ALLOWED:-true}" # set to false after your team joined!
# --- Single sign-on (episode 4 with Forgejo, since episode 15 Pocket ID) ---
# Create the OIDC client in Pocket ID FIRST. Its callback URL must be exactly:
# https://vault.${DOMAIN}/identity/connect/oidc-signin
# That path is not documented in the UI. Getting it wrong fails only
# AFTER the provider login, which is why it costs people twenty minutes.
SSO_ENABLED: "true"
# Just the base URL. Vaultwarden reads the discovery document
# (/.well-known/openid-configuration) from here on the first login.
# SSO_AUTH_ONLY_NOT_SESSION stays false (default): every token refresh
# asks the provider again, so a person disabled there is out at the next
# refresh. Episode 15 measured why that alone was not enough with Forgejo.
SSO_AUTHORITY: https://id.${DOMAIN}
SSO_CLIENT_ID: ${VAULTWARDEN_SSO_CLIENT_ID}
SSO_CLIENT_SECRET: ${VAULTWARDEN_SSO_CLIENT_SECRET}
# Vaultwarden prepends "openid" itself, so listing it here again shows up
# duplicated on the consent screen. Harmless, but untidy.
SSO_SCOPES: "profile email"
# Attach an SSO login to the existing account with the same mail address
# instead of creating a duplicate. Turn this on BEFORE people log in.
SSO_SIGNUPS_MATCH_EMAIL: "true"
volumes:
- vaultwarden_data:/data
networks: [proxy]
uptime-kuma:
image: louislam/uptime-kuma:1
restart: unless-stopped
volumes:
- kuma_data:/app/data
networks: [proxy]
# Push notifications for the monitoring alerts (episode 6).
# Uptime Kuma publishes to the topic "alerts", your phone subscribes to it.
ntfy:
image: binwiederhier/ntfy:v2.28.0
restart: unless-stopped
command: serve
environment:
NTFY_BASE_URL: https://ntfy.${DOMAIN}
NTFY_BEHIND_PROXY: "true"
NTFY_AUTH_DEFAULT_ACCESS: deny-all # nobody reads or writes without a user
NTFY_AUTH_FILE: /var/lib/ntfy/user.db
NTFY_CACHE_FILE: /var/cache/ntfy/cache.db
NTFY_ATTACHMENT_CACHE_DIR: /var/cache/ntfy/attachments
NTFY_ENABLE_LOGIN: "true"
volumes:
- ntfy_data:/var/lib/ntfy
- ntfy_cache:/var/cache/ntfy
networks: [proxy]
# The automation hub (episode 8). The editor stays on the mesh (episode 5);
# Caddy publishes nothing but the webhook paths. Internal senders (forgejo,
# uptime-kuma) call http://n8n:5678/webhook/... over the docker network and
# never leave the box at all.
n8n:
# Pinned. With :latest every restore from backup is silently an upgrade,
# and an upgrade is a thing you schedule, not a thing a restore does to you.
image: docker.n8n.io/n8nio/n8n:2.37.7
restart: unless-stopped
environment:
N8N_HOST: n8n.${DOMAIN}
N8N_PORT: "5678"
N8N_PROTOCOL: https
# The address n8n prints under every webhook node. It must be the public
# one, otherwise you hand your senders a URL that resolves nowhere.
# (2.x renamed WEBHOOK_URL to this. The old name still works and prints a
# deprecation line on every boot.)
N8N_WEBHOOK_URL: https://n8n.${DOMAIN}/
GENERIC_TIMEZONE: ${TZ:-Europe/Berlin}
TZ: ${TZ:-Europe/Berlin}
# The editor is opened over the mesh on plain http://<container-ip>:5678.
# With secure cookies on, the browser never sends the session cookie back
# and the login screen just reappears, with no error anywhere.
N8N_SECURE_COOKIE: "false"
# Do not let anyone install unverified community node packages here.
# The default flips to false in a later version anyway.
N8N_UNVERIFIED_PACKAGES_ENABLED: "false"
# File nodes may read exactly one directory and nothing else. Left wide
# open, a workflow could read /home/node/.n8n/config - the file that
# decrypts every credential in the instance.
N8N_RESTRICT_FILE_ACCESS_TO: /backup
# Optional: pin the credential encryption key yourself instead of letting
# n8n write one into the volume. See .env.example before you set this.
N8N_ENCRYPTION_KEY: ${N8N_ENCRYPTION_KEY:-}
# A company box does not need to phone home.
N8N_DIAGNOSTICS_ENABLED: "false"
N8N_VERSION_NOTIFICATIONS_ENABLED: "false"
N8N_HIRING_BANNER_ENABLED: "false"
volumes:
- n8n_data:/home/node/.n8n
# Read-only view of the nightly backup log (episode 7) so the digest
# workflow can report the last restic snapshot. Optional: drop this line
# and the digest says "no backup log found" instead of failing.
- ./backup/backup.log:/backup/backup.log:ro
networks: [proxy]
# Identity provider (episode 15). Forgejo used to be the OIDC provider for
# outline, vaultwarden and netbird. Now everyone logs in here, with a
# passkey, and switching one person off happens in one place. Pocket ID is
# small enough for a 4 GB box; groups decide who gets which service.
pocket-id:
image: ghcr.io/pocket-id/pocket-id:v2.16.0
restart: unless-stopped
environment:
APP_URL: https://id.${DOMAIN}
# Caddy sits in front, so trust X-Forwarded-For for the audit log.
TRUST_PROXY: "true"
# Encrypts the signing keys at rest. Generate once: openssl rand -base64 32
ENCRYPTION_KEY: ${POCKET_ID_ENCRYPTION_KEY}
ANALYTICS_DISABLED: "true"
volumes:
- pocket_id_data:/app/data
networks: [proxy]
# Login gate for services without their own SSO (episode 15). Caddy asks
# tinyauth on every request (forward_auth); tinyauth sends people to Pocket
# ID and lets through only members of the right group. No docker socket,
# apps are configured below, not via labels.
tinyauth:
image: ghcr.io/tinyauthapp/tinyauth:v5.2.0
restart: unless-stopped
environment:
TINYAUTH_APPURL: https://auth.${DOMAIN}
TINYAUTH_LABELPROVIDER: none
TINYAUTH_ANALYTICS_ENABLED: "false"
TINYAUTH_DATABASE_PATH: /data/tinyauth.db
TINYAUTH_AUTH_SECURECOOKIE: "true"
TINYAUTH_AUTH_TRUSTEDPROXIES: 172.18.0.0/16
TINYAUTH_OAUTH_AUTOREDIRECT: pocketid
TINYAUTH_OAUTH_PROVIDERS_POCKETID_NAME: Pocket ID
TINYAUTH_OAUTH_PROVIDERS_POCKETID_CLIENTID: ${TINYAUTH_CLIENT_ID}
TINYAUTH_OAUTH_PROVIDERS_POCKETID_CLIENTSECRET: ${TINYAUTH_CLIENT_SECRET}
TINYAUTH_OAUTH_PROVIDERS_POCKETID_AUTHURL: https://id.${DOMAIN}/authorize
TINYAUTH_OAUTH_PROVIDERS_POCKETID_TOKENURL: https://id.${DOMAIN}/api/oidc/token
TINYAUTH_OAUTH_PROVIDERS_POCKETID_USERINFOURL: https://id.${DOMAIN}/api/oidc/userinfo
TINYAUTH_OAUTH_PROVIDERS_POCKETID_REDIRECTURL: https://auth.${DOMAIN}/api/oauth/callback/pocketid
TINYAUTH_OAUTH_PROVIDERS_POCKETID_SCOPES: openid email profile groups
# Uptime Kuma: only the Pocket ID group "ops" gets past the gate.
TINYAUTH_APPS_KUMA_CONFIG_DOMAIN: status.${DOMAIN}
TINYAUTH_APPS_KUMA_OAUTH_GROUPS: ops
volumes:
- tinyauth_data:/data
networks: [proxy]
# Optional starter VPN — enable with: docker compose --profile vpn up -d
# (The proper Netbird mesh deployment is Episode 5.)
wg-easy:
image: ghcr.io/wg-easy/wg-easy:14
profiles: [vpn]
restart: unless-stopped
cap_add: [NET_ADMIN, SYS_MODULE]
sysctls:
net.ipv4.ip_forward: 1
net.ipv4.conf.all.src_valid_mark: 1
environment:
WG_HOST: vpn.${DOMAIN}
PASSWORD_HASH: ${WGEASY_PASSWORD_HASH}
ports:
- "51820:51820/udp"
volumes:
- wg_data:/etc/wireguard
networks: [proxy]
# The only thing in this stack that is allowed to change state (episode 9).
# The agent never talks to it. n8n does, and only after a switch node has
# matched the model's proposed action against a fixed list. This service
# checks the same thing again, plus a second list of containers it may
# touch at all. Two locks on one door cost nothing.
#
# Start it with: docker compose --profile agent up -d
ops-actions:
image: docker:cli
profiles: [agent]
restart: unless-stopped
environment:
OPS_ACTIONS_TOKEN: ${OPS_ACTIONS_TOKEN}
volumes:
- ./ops-actions/runner.py:/runner.py:ro
# Yes, this is the docker socket. That is exactly why the allowlists are
# inside this container and not in a workflow anyone can edit.
- /var/run/docker.sock:/var/run/docker.sock
entrypoint: ["sh", "-c"]
command: ["apk add --no-cache python3 >/dev/null && exec python3 -u /runner.py"]
networks: [proxy]
# The read-only twin of ops-actions (episode 11). Behind the security report
# agents: three read-only mounts, a fixed list of questions, no docker
# socket, no shell. The agents get answers to these questions and nothing
# else. Token comes from ops-readonly/token.env (see token.env.example), so
# .env does not grow another secret.
#
# Start it with: docker compose --profile agent up -d
ops-readonly:
build: ./ops-readonly
image: selfhostedops/ops-readonly:ep11
profiles: [agent]
restart: unless-stopped
read_only: true
cap_drop: [ALL]
security_opt: ["no-new-privileges:true"]
env_file:
- path: ./ops-readonly/token.env
required: false
volumes:
- ./logs/caddy:/logs/caddy:ro
- /var/log/journal:/var/log/journal:ro
- /var/run/fail2ban:/run/fail2ban:ro
# episode 14: WAF alerts as JSON, written on the host by ops-readonly/waf-export.py
- /var/lib/ops-readonly/waf:/waf:ro
# episode 16: Kuma state as JSON, written on the host by ops-readonly/kuma-export.py
- /var/lib/ops-readonly/kuma:/kuma:ro
networks: [proxy]
# --- CI (episode 2) -- start with: docker compose --profile ci up -d ---
docker-in-docker:
image: data.forgejo.org/oci/docker:dind
# The hostname MUST be "docker": the TLS certificates dind generates are
# only valid for that name. Rename it and you get a handshake error that
# reads like a network problem and is not one.
hostname: docker
profiles: [ci]
privileged: true
restart: unless-stopped
environment:
DOCKER_TLS_CERTDIR: /certs
volumes:
- docker_certs:/certs
- dind_data:/var/lib/docker
networks: [ci]
forgejo-runner:
image: data.forgejo.org/forgejo/runner:13.0.0
profiles: [ci]
restart: unless-stopped
depends_on: [docker-in-docker]
environment:
DOCKER_HOST: tcp://docker:2376
DOCKER_CERT_PATH: /certs/client
DOCKER_TLS_VERIFY: "1"
volumes:
- runner_data:/data
- docker_certs:/certs
- ./runner-config.yml:/data/config.yml:ro
networks: [ci, proxy]
command: forgejo-runner --config /data/config.yml daemon