Measured on the demo box against the same 24h of real traffic. Episode 12 (detection, blocklist, firewall): - crowdsec/ with install notes and the traps: port 8080 clash, empty first blocklist pull, replay forensic-mode dropping events (FIFO workaround), ssh-time-based-bf shipping remediation:false, the AppSec collections that refuse to start without extra rules. - firewall bouncer in nftables mode (input + forward), which blocks Docker-published ports and survives ufw reload; iptables/DOCKER-USER does not. - HARDENING.md episode 12 section with the numbers: fail2ban 4 banned IPs vs CrowdSec 23 detected / 1 blocked, blocklist overlap 50% of IPs but 19% of attempts, and the mode=normal blind spot for key-only root attempts. Episode 13 (AppSec WAF): - caddy/Dockerfile now builds the CrowdSec bouncer and AppSec handler alongside rate-limit, pinned to v0.14.1. - Caddyfile imports a crowdsec snippet (bouncer + appsec) on every site except vpn; compose passes CROWDSEC_BOUNCER_KEY and host.docker.internal. - Measured: virtual patching blocks known CVEs only (7 of 41 probes, near-zero false positives); OWASP CRS raises that to 27 of 41 but breaks git push and wiki editing at default paranoia. Default stays vpatch+generic. fail2ban config unchanged. No secrets or real domains/IPs in tracked files.
349 lines
13 KiB
YAML
349 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
|
|
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
|
|
|