Initial stack: Caddy, Forgejo, Outline, Vaultwarden, Uptime Kuma, n8n

This commit is contained in:
Maximilian Grimm 2026-08-16 19:29:58 +02:00
commit 06f8e8812e
6 changed files with 401 additions and 0 deletions

1
.gitignore vendored Normal file
View file

@ -0,0 +1 @@
.env

30
Caddyfile Normal file
View file

@ -0,0 +1,30 @@
# ~$ selfhostedops — Caddyfile
# Automatic HTTPS for every subdomain. Nothing to renew, ever.
{
email {$ACME_EMAIL}
}
git.{$DOMAIN} {
reverse_proxy forgejo:3000
}
wiki.{$DOMAIN} {
reverse_proxy outline:3000
}
vault.{$DOMAIN} {
reverse_proxy vaultwarden:80
}
status.{$DOMAIN} {
reverse_proxy uptime-kuma:3001
}
n8n.{$DOMAIN} {
reverse_proxy n8n:5678
}
vpn.{$DOMAIN} {
reverse_proxy wg-easy:51821
}

94
HARDENING.md Normal file
View file

@ -0,0 +1,94 @@
# ~$ Hardening checklist
This is the non-negotiable baseline before real company data touches the box.
Every item is copy-paste ready for Debian/Ubuntu.
## 1. SSH: keys only, no root
```bash
adduser ops && usermod -aG sudo,docker ops
mkdir -p /home/ops/.ssh && cp ~/.ssh/authorized_keys /home/ops/.ssh/ \
&& chown -R ops:ops /home/ops/.ssh && chmod 700 /home/ops/.ssh
```
`/etc/ssh/sshd_config.d/hardening.conf`:
```
PermitRootLogin no
PasswordAuthentication no
KbdInteractiveAuthentication no
```
```bash
systemctl restart ssh
# TEST A NEW SSH SESSION BEFORE CLOSING THIS ONE.
```
## 2. Firewall — four ports, nothing else
```bash
apt install -y ufw
ufw default deny incoming
ufw allow 80/tcp 443/tcp 443/udp # caddy (http/https/h3)
ufw allow 2222/tcp # git ssh
ufw allow 51820/udp # wireguard (only if using --profile vpn)
ufw allow 22/tcp # your ssh (move it if you want)
ufw enable
```
Note: Docker's published ports bypass ufw INPUT rules — that's exactly why this
compose file publishes only caddy, git-ssh and wireguard, and keeps postgres/redis
on the `internal` network with no ports at all. Don't add `ports:` to internal
services "for debugging" and forget them.
## 3. Automatic security updates
```bash
apt install -y unattended-upgrades
dpkg-reconfigure -plow unattended-upgrades
```
## 4. fail2ban (ssh brute force)
```bash
apt install -y fail2ban # default ssh jail is enough to start
```
## 5. Vaultwarden specifics
- `VAULTWARDEN_SIGNUPS_ALLOWED=false` the moment your team is on board.
- Use an argon2 `ADMIN_TOKEN` (`vaultwarden hash` / see wiki), not a plain string.
- The admin panel (`/admin`) is for setup — consider blocking it in Caddy afterwards:
```
vault.{$DOMAIN} {
@admin path /admin*
respond @admin 404
reverse_proxy vaultwarden:80
}
```
## 6. Pin your images
`latest` is fine on day one, dangerous on day 300. Pin majors at minimum
(`caddy:2-alpine`, `forgejo:11`, `uptime-kuma:1` already are). Watchtower blindly
auto-updating a password manager at 3 a.m. is how you get incidents — update
deliberately: `docker compose pull && docker compose up -d` after reading release notes.
n8n and Outline move fast; Vaultwarden occasionally has breaking web-vault pairs.
## 7. Backups (minimum viable, today)
```bash
# stop, snapshot volumes, start — cron it nightly until Episode 10 (borgmatic offsite)
docker compose stop
tar czf /backup/stack-$(date +%F).tgz -C /var/lib/docker/volumes .
docker compose start
```
Plus: enable your provider's server snapshots. Two clicks, saves your company.
## 8. What this list is NOT
Not covered here, on purpose (own episodes): SSO everywhere, 2FA enforcement
per service, Netbird zero-trust mesh, central logging, CrowdSec, offsite borg.
Baseline first. Perfect later.

9
LICENSE Normal file
View file

@ -0,0 +1,9 @@
MIT License
Copyright (c) 2026 SelfHostedOps
Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.

102
README.md Normal file
View file

@ -0,0 +1,102 @@
# ~$ 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.<DOMAIN>/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.<DOMAIN>`. 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.
## 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)**

165
compose.yml Normal file
View file

@ -0,0 +1,165 @@
# ~$ 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:
internal:
volumes:
caddy_data:
caddy_config:
forgejo_data:
outline_data:
outline_pg:
vaultwarden_data:
kuma_data:
n8n_data:
wg_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:11
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"
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!
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]