# ~$ 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://: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] # --- 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