- 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
224 lines
7.1 KiB
YAML
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
|
|
|