# ~$ selfhostedops-stack **The €15/month self-hosted company stack.** Git hosting, wiki, password manager, monitoring, automation and VPN — one server, one compose file, no per-seat pricing. From the video: **[Self-Hosted Company Stack for €15/Month (Full Setup)](https://youtube.com/@selfhostedops)** — Series *Self-Hosted Company OS*, Episode 1. ``` ┌──────────────────────────────────────┐ │ caddy :80/:443 │ │ automatic HTTPS for *.DOMAIN │ └──┬─────┬───────┬───────┬──────┬──────┘ │ │ │ │ │ git.─────► │ wiki.──► │ status.──► │ │ │ │ │ │ ┌────▼─┐ ┌─▼────┐ ┌▼─────┐ ┌▼───┐ ┌▼────┐ │forge │ │outline│ │vault │ │kuma│ │ n8n │ │ jo │ │+pg+redis│warden│ │ │ │ │ └──────┘ └──────┘ └──────┘ └────┘ └─────┘ also: wg-easy (optional, --profile vpn) ``` | Service | URL | Replaces | Typical SaaS price | |---|---|---|---| | [Forgejo](https://forgejo.org) | `git.…` | GitHub Team | $4/user/mo | | [Outline](https://www.getoutline.com) | `wiki.…` | Notion | $10/user/mo | | [Vaultwarden](https://github.com/dani-garcia/vaultwarden) | `vault.…` | 1Password | $8/user/mo | | [Uptime Kuma](https://github.com/louislam/uptime-kuma) | `status.…` | Pingdom & co. | $20+/mo | | [n8n](https://n8n.io) | `n8n.…` | Zapier | $30+/mo | | [wg-easy](https://github.com/wg-easy/wg-easy) | `vpn.…` | Tailscale | $6/user/mo | All images are multi-arch (amd64 + arm64) — a €14 ARM cloud box (e.g. Hetzner CAX31) runs the whole thing with room to spare. ## Quickstart ```bash # 0. A Debian/Ubuntu server with Docker: curl -fsSL https://get.docker.com | sh # 1. DNS: ONE wildcard record *.ops.example.com -> your server IP (A + AAAA) # 2. Configure git clone https://github.com/selfhostedops/selfhostedops-stack.git cd selfhostedops-stack cp .env.example .env nano .env # domain, email, 3x openssl rand -hex 32 # 3. Launch docker compose up -d ``` Ninety seconds later: `git.` `wiki.` `vault.` `status.` `n8n.` — all with valid HTTPS. ## First-run order (5 minutes) 1. **Forgejo** (`git.…`): create the admin account on the setup page. Registration is already disabled via env — this is a company server. 2. **Forgejo → SSO for the wiki:** Site Administration → Applications → Create OAuth2 app. Redirect URI: `https://wiki./auth/oidc.callback`. Put the client id/secret into `.env`, then `docker compose up -d outline`. 3. **Outline** (`wiki.…`): click "Continue with Forgejo". Your git server is your SSO provider — one account, both tools. 4. **Vaultwarden** (`vault.…`): create your account, install the Bitwarden apps against `https://vault.`. When the team is on board: `VAULTWARDEN_SIGNUPS_ALLOWED=false` and re-up. 5. **Uptime Kuma** (`status.…`): create admin, add HTTPS monitors for the other five subdomains. Meta, but correct. 6. **n8n** (`n8n.…`): create the owner account. This thing becomes the brain of the stack in later episodes (AI agents incoming). ## FAQ **Why no Netbird? You said mesh VPN.** Full self-hosted Netbird (management plane, signal, TURN, IdP) deserves its own video — that's Episode 5. `wg-easy` gets you a working WireGuard VPN today with one env var. Skip both if you don't need remote access yet. **Backups?** `docker compose down`, snapshot/borg the named volumes, `up -d`. A proper 3-2-1 setup (borgmatic + offsite) is Episode 10. Until then: at minimum enable your host provider's snapshot backups. **Can I run only some services?** Yes — comment out what you don't need in `compose.yml` and remove its Caddyfile block. Everything is independent except Outline → Forgejo (SSO) and Outline → its postgres/redis. **Is `latest` okay for images?** For a homelab: pragmatic. For production: pin versions and read HARDENING.md. ## CI: your own runners (Episode 2) Forgejo ships Actions. Turn it on, give it a runner, and you have GitHub Actions with no minute counter. ```bash # 1. Enable it (already set in compose.yml) # FORGEJO__actions__ENABLED and repo.actions in DEFAULT_REPO_UNITS. # Without repo.actions in the default units, the Actions tab stays hidden # and it looks like the feature is broken. Existing repos: flip it per repo. # 2. One shared secret for declarative registration SECRET=$(openssl rand -hex 20) UUID=$(python3 -c 'import uuid,sys;print(uuid.UUID(bytes=sys.argv[1][:16].encode()))' "$SECRET") printf 'RUNNER_SECRET=%s\nRUNNER_UUID=%s\n' "$SECRET" "$UUID" >> .env # 3. Tell Forgejo about it docker compose exec -u git forgejo forgejo forgejo-cli actions register \ --name box-runner --labels docker --secret "$SECRET" # 4. Fill in the runner config and start the CI profile cp runner-config.example.yml runner-config.yml sed -i "s|https://git.example.com|https://git.$DOMAIN|; s|\${RUNNER_UUID}|$UUID|; s|\${RUNNER_SECRET}|$SECRET|" runner-config.yml docker compose --profile ci up -d ``` Then check Site Administration → Runners: the runner should be online with the label `docker`. **Four things that cost people an evening:** - A registration **token from the web UI is not a shared secret**. The declarative path above only accepts the secret you generated. Feed it the web token and the runner connects and never receives a job, with no error anywhere. - The `connections:` block lives under a top-level **`server:`** key in runner 13.x. Older examples put it under `runner:`, which fails with `0 server connections configured`. - **An empty `labels:` list means every job waits forever.** No error, just a queued job and a healthy-looking runner. - The image behind a label **must contain node** — Forgejo actions are JavaScript. Pointing `docker` at a docker-cli image gives you `node: executable file not found`. Use a node image and let the build job fetch a static docker client (see `examples/ci.yml`). `examples/ci.yml` is a complete pipeline: test, build a container, push it to Forgejo's built-in registry, and deploy it over an SSH key that can only run one command (`examples/deploy-hello-ops.sh`). Read the CI section of [HARDENING.md](HARDENING.md) before you wire the deploy step — there are two comfortable ways to hand your whole server to anyone who can push a branch. ## One login for the vault (Episode 4) Your Forgejo already knows your team. Let it log them into the password manager too, so offboarding is one switch instead of a checklist. ```bash # 1. Create the OAuth2 app in Forgejo # Site Administration -> Applications -> OAuth2 Application # Name: Vaultwarden # Redirect URI: https://vault./identity/connect/oidc-signin # Confidential client: yes # # That redirect path is not documented anywhere in the UI, and a wrong one # only fails AFTER the Forgejo login. Copy it character for character. # 2. Put the client id and secret in .env VAULTWARDEN_SSO_CLIENT_ID=... VAULTWARDEN_SSO_CLIENT_SECRET=... # 3. Restart just that container docker compose up -d vaultwarden # 4. Log in: https://vault. # Type your work mail, hit "Use single sign-on", authorize once in Forgejo. ``` Vaultwarden will then ask you to set a master password. That is not a bug. Forgejo proved *who you are*; nothing so far produced the key that *decrypts your vault*. Your master password never leaves your browser, and the server stores blobs it cannot read. Two different jobs, two different mechanisms. Debugging notes, all of them learned the hard way: - Vaultwarden logs nothing about SSO at startup. It builds the OIDC client on the first login attempt, not before. Watch `docker compose logs -f vaultwarden` *while* you log in instead: you will see a `prevalidate` call and then the `oidc-signin` callback coming back with a code. - `/identity/connect/authorize` and the callback both answer `404` when called without query parameters. That is route matching, not a wrong path. - `curl -I` on Forgejo's `/.well-known/openid-configuration` returns 404 because there is no HEAD route. Use `curl -s` and it is a normal 200. - Turn on `SSO_SIGNUPS_MATCH_EMAIL` **before** anyone logs in, or you get duplicate accounts instead of linked ones. Then make it a team vault: create an organization, add collections, invite people. Note that joining is three steps, not two: you invite, they log in, and then an owner has to **confirm** them. Until that confirmation, a member sees no shared items at all. ## Hardening Before you put real company data on this box, read **[HARDENING.md](HARDENING.md)**. The short version: SSH keys only, firewall (80/443/2222/51820 only), unattended security updates, fail2ban, pinned image tags, offsite backups. --- MIT — do whatever. If it saved you money, a YouTube subscription is the price: **[@selfhostedops](https://youtube.com/@selfhostedops)**