selfhostedops-stack/compose.yml
Maximilian Grimm ccbddab46c Episode 9: an ops triage agent that proposes but never executes
The agent runs in n8n on the box and reaches its model over the mesh from
episode five, so no prompt leaves the network. It gets three read-only tools
(recent commits, monitor status, wiki search) and returns one suggested
action as a field. A switch compares that field against three allowed values,
and only one of them reaches ops-actions, a small runner holding its own
hard-coded allowlists for actions and targets.

Measured while building this: the same prompt injection was harmless in every
place the agent reached through a tool (twenty runs, no hits) and worked every
time it arrived in the alert itself, through a poisoned monitor name (thirteen
of thirteen). The allowlist rejected all thirteen. It guards the action field
only, so the attacker-written summary still reached the wiki unchanged; the
triage note now truncates and strips every field it did not write itself.
2026-09-06 13:55:45 +02:00

320 lines
12 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 (also acts as SSO/OIDC provider)
# outline team wiki (logs in via Forgejo SSO)
# 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:
wg_data:
docker_certs:
dind_data:
runner_data:
services:
caddy:
image: caddy:2-alpine
restart: unless-stopped
ports:
- "80:80"
- "443:443"
- "443:443/udp" # HTTP/3
environment:
DOMAIN: ${DOMAIN}
ACME_EMAIL: ${ACME_EMAIL}
volumes:
- ./Caddyfile:/etc/caddy/Caddyfile:ro
- 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"
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 your own Forgejo (set up the OAuth2 app first, see README) ---
OIDC_CLIENT_ID: ${OUTLINE_OIDC_CLIENT_ID}
OIDC_CLIENT_SECRET: ${OUTLINE_OIDC_CLIENT_SECRET}
OIDC_AUTH_URI: https://git.${DOMAIN}/login/oauth/authorize
OIDC_TOKEN_URI: https://git.${DOMAIN}/login/oauth/access_token
OIDC_USERINFO_URI: https://git.${DOMAIN}/login/oauth/userinfo
OIDC_LOGOUT_URI: https://git.${DOMAIN}/logout
OIDC_DISPLAY_NAME: Forgejo
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 via your own Forgejo (episode 4) ---
# Create the OAuth2 app in Forgejo FIRST: Site Administration ->
# Applications -> OAuth2. Its redirect URI 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 Forgejo login, which is why it costs people twenty minutes.
SSO_ENABLED: "true"
# Just the base URL. Vaultwarden reads Forgejo's discovery document
# (/.well-known/openid-configuration) from here on the first login.
SSO_AUTHORITY: https://git.${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 Forgejo 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]
# 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]
# --- 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