selfhostedops-stack/compose.yml
Maximilian Grimm d5398caece Episode 4: Vaultwarden single sign-on via Forgejo
- compose: SSO_ENABLED, SSO_AUTHORITY and the two client credentials, with the
  undocumented redirect path written down where people will look for it
- .env.example: was referenced by the README since episode 1 but never actually
  committed, so a fresh clone could not follow the quickstart
- README: the four steps, plus the debugging notes that cost the most time
  (no SSO line at startup, 404 on routes without query parameters, HEAD on the
  discovery endpoint, and the three-step member join)
- HARDENING: never copy db.sqlite3 alone while the container runs. WAL mode
  keeps recent writes in a side file, so that backup silently misses data
2026-08-26 21:54:23 +02:00

224 lines
7.1 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
# n8n automation hub
# wg-easy WireGuard VPN (optional — start with: --profile vpn)
# (full Netbird mesh gets its own episode)
#
# 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:
ci:
internal:
volumes:
caddy_data:
caddy_config:
forgejo_data:
outline_data:
outline_pg:
vaultwarden_data:
kuma_data:
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"
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]
n8n:
image: docker.n8n.io/n8nio/n8n:latest
restart: unless-stopped
environment:
N8N_HOST: n8n.${DOMAIN}
N8N_PORT: "5678"
N8N_PROTOCOL: https
WEBHOOK_URL: https://n8n.${DOMAIN}/
GENERIC_TIMEZONE: ${TZ:-Europe/Berlin}
TZ: ${TZ:-Europe/Berlin}
volumes:
- n8n_data:/home/node/.n8n
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