ops-readonly gets two read-only questions, waf_blocked and waf_rule_info. The rule ids per blocked request are in CrowdSec's alerts, not in its log, and alerts need machine credentials that can also write, so waf-export.py runs on the host and the container gets one more read-only mount. Workflow G lets a local 3B model draft CRS route exclusions as a Forgejo issue. The model never writes rule syntax, a code node checks every field against the tool output, the only write permission is an issue in one repo, trusted sources live in the workflow and the trigger needs a token. waf-propose.py does the same without a model: 5 of 5 false positives, no exclusion for an attack, 0.03 s. The model proposed an exclusion for nearly every attack it saw. Numbers and the traps we hit are in HARDENING.md.
351 lines
13 KiB
YAML
351 lines
13 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: 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"
|
|
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]
|
|
|
|
# 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
|
|
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
|
|
|