selfhostedops-stack/compose.yml
grimm bb2b5b5e22 Episode 8: n8n as the automation hub, with the editor kept off the public internet
Three workflows that connect the services from episodes 1 to 7: a Forgejo push
becomes a phone notification, an Uptime Kuma outage writes and closes its own
incident page in Outline, and a nightly schedule writes one ops digest.

The point of the episode is the split between webhooks and the editor. Caddy
publishes only the webhook path prefixes under the n8n host and answers an
empty 404 for everything else, so the editor stays reachable over the mesh from
episode 5 and never gets a login page on the open internet.

Also in here:
- n8n pinned to a version instead of :latest, after episode 7 showed that every
  restore is otherwise an upgrade
- the 2.x environment that actually matters, including the file access
  restriction pointed at one directory rather than the root
- the Forgejo allowed host list, without which a webhook to a private address
  is silently dropped
- fixed subnets for both docker networks, after a restore handed them out in
  the other order and quietly broke the mesh route
- a note on the encryption key: it decrypts every stored credential, it lives in
  the n8n volume, and a backup without it restores a hub that cannot log in

Claude-Session: https://claude.ai/code/session_01Md8yhBKp5e7xMmCyxoEgdm
2026-09-05 12:54:11 +02:00

297 lines
11 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]
# --- 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