Small monorepo for self-hosting of personal apps, in the form of dockerized services on my home server that route through a Pangolin container on a DigitalOcean Droplet.
  • Shell 69.6%
  • Makefile 15.6%
  • Python 12.7%
  • Dockerfile 2.1%
Find a file
Repository files (latest commit first)
Filename Latest commit message Latest commit date
2026-10-16 11:23:49 -06:00
media-pipeline Rename phd-server to phd-beelink; add phd-tower and phd-razer site hosts (#34) 2026-10-11 22:55:40 -06:00
ofelia Rename phd-server to phd-beelink; add phd-tower and phd-razer site hosts (#34) 2026-10-11 22:55:40 -06:00
pangolin/config Disable CrowdSec http-probing on the edge (#54) 2026-10-14 15:51:36 -06:00
restic Stub the phd-tower backup, deliberately unplugged (#51) 2026-10-12 22:26:08 -06:00
scripts Add a pre-push secret gate (#59) 2026-10-15 23:49:07 -06:00
.env.example Remove the Obsidian Sync services from the stack (#61) 2026-10-16 00:18:53 -06:00
.gitignore Authenticate pangolin-client via a persisted login session (#45) 2026-10-12 20:28:44 -06:00
docker-compose.droplet.yml Fix DISABLE_SCENARIOS separator: space, not comma 2026-10-14 15:53:07 -06:00
docker-compose.phd-beelink.yml Remove the Obsidian Sync services from the stack (#61) 2026-10-16 00:18:53 -06:00
docker-compose.phd-razer.yml Remove the Obsidian Sync services from the stack (#61) 2026-10-16 00:18:53 -06:00
docker-compose.phd-tower.yml Remove the Obsidian Sync services from the stack (#61) 2026-10-16 00:18:53 -06:00
LICENSE Add MIT License to the project 2026-03-21 16:11:08 -06:00
Makefile Add a pre-push secret gate (#59) 2026-10-15 23:49:07 -06:00
README.md Update README to test push mirror 2026-10-16 11:23:49 -06:00

Cumulus

Self-hosted services running on personal infrastructure, managed with Docker Compose. Nothing too fancy or novel; I just wanted to share how I coordinate some of my favorite self-hosted services.

Services

The stack is split across four hosts, each with its own compose file:

Droplet (DigitalOcean VPS) is the public-facing edge:

Service Description
Pangolin Reverse proxy & tunnel management
Gerbil WireGuard tunnel agent for Pangolin
Traefik Edge router handling HTTPS termination and routing
CrowdSec Edge IPS/WAF — bans malicious IPs and inspects requests via a Traefik bouncer plugin

phd-beelink (home server) is where the actual applications run:

Service Description
Pangolin CLI (site) Inbound tunnel agent (pangolin-site container), authenticated with the site's ID/secret from .env
Pangolin CLI (client) Outbound tunnel agent (pangolin-client container), authenticated by a one-time interactive pangolin login persisted in pangolin-client-config/; also maintains the host's .internal entries in /etc/hosts
Jellyfin Media server for movies, TV, music
Immich Self-hosted photo & video management
Immich ML Machine learning sidecar for face/object recognition
Immich Redis Caching layer for Immich (Valkey)
Immich Database PostgreSQL with vector extensions for Immich search
Open WebUI LLM chat interface with model management
Gokapi Lightweight file-drop / sharing service (Firefox Send alternative)

phd-tower (tower PC) runs Pangolin site and client agents and the Forgejo forge:

Service Description
Pangolin CLI (site / client) The two-role agent pair (pangolin-site + pangolin-client), same auth split as phd-beelink
Forgejo Self-hosted git forge with Git LFS at forge.${BASE_DOMAIN} — web UI and git-over-HTTPS through a Pangolin HTTP resource, git-over-SSH through a raw TCP resource on port 2222
forge-backup Stubbed: monthly restic backup of ForgeData, home, and the forge DB dump to a DigitalOcean Spaces cold bucket — dormant behind the backups compose profile until plugged in

phd-razer (workstation) runs the Pangolin site and client agents:

Service Description
Pangolin CLI (site / client) The two-role agent pair (pangolin-site + pangolin-client), same auth split as phd-beelink

How it all fits together

On the Droplet: Pangolin, Gerbil, and Traefik run on a small cloud VPS and act as the public entry point with whatever auth/routing controls I need. Traefik terminates HTTPS and Gerbil manages WireGuard tunnels. CrowdSec sits alongside Traefik as an edge IPS/WAF: a bouncer plugin is attached to Traefik's websecure entry point, so every HTTPS request is checked against CrowdSec's decisions (community blocklist + locally-detected bad actors) and inspected by its AppSec engine before it reaches any service. It has no published ports — Traefik reaches it over the internal network — and its WAF is configured to fail open, so a CrowdSec outage never takes the edge down. On the home server, Newt establishes an outbound tunnel back to the Pangolin endpoint, so services like Jellyfin and Immich are reachable from the internet without exposing the home network or forwarding ports on the router. The tower and the workstation connect the same way — their site agents dial out to Pangolin, making each machine a Pangolin site.

On the Server:

  • Immich runs as a small cluster of containers: the main server, a machine-learning worker, Redis for caching, and a Postgres database with pgvector for similarity search. Its library and database are stored on a separate drive for capacity reasons.
  • Jellyfin similarly mounts its media from a larger capacity drive.
  • Open WebUI provides a browser-based chat interface, backed by Ollama Cloud as an OpenAI-compatible connection — the connection and key are set from its admin UI.
  • Gokapi is a lightweight file-drop service (a self-hosted Firefox Send alternative) for sharing files via expiring links, reachable at drop.${BASE_DOMAIN}. Like Jellyfin and Immich it's configured through a first-run web setup wizard (https://drop.${BASE_DOMAIN}/setup) where you set the admin account and the public URL. Uploaded files are bind-mounted from a capacity drive via GOKAPI_DATA_PATH (they can accumulate), while the small config and SQLite database live in the gokapi-config named volume. It listens on 127.0.0.1:53842 and is exposed through Pangolin as a standard HTTP resource pointing at localhost:53842.

Each host is managed independently via the Makefile (e.g. make up droplet, make logs phd-beelink).

Quick Start

The manual config to get set up is pretty minimal; Pangolin, Immich, and Jellyfin all have great UIs for handling the majority of the relevant config.

# 1. Set your environment variables
cp .env.example .env
# Edit .env — set BASE_DOMAIN, ACME_EMAIL, and the secrets for your host

# 2. Run setup (creates directories and generates config from templates)
make setup droplet        # or: make setup phd-beelink

# 3. Start services
make up droplet           # or: make up phd-beelink

What gets configured where

.env is the single source of truth for all settings — domains, secrets, paths, and credentials. Edit this first; everything else is derived from it.

make setup <host> uses envsubst to generate the actual config files from templates:

Template Generated file Key variables
pangolin/config/config.yml.template config.yml BASE_DOMAIN
pangolin/config/traefik/traefik_config.yml.template traefik_config.yml ACME_EMAIL
pangolin/config/traefik/dynamic_config.yml.template dynamic_config.yml BASE_DOMAIN, CROWDSEC_LAPI_KEY

The generated files are gitignored, so you must run make setup on each host after cloning. Do not copy the .template files directly — the ${VAR} placeholders won't be substituted at runtime.

Applying config changes: make setup <host> re-renders the templates, but make up <host> won't restart a container whose service definition is unchanged — Docker Compose doesn't notice edits to the contents of bind-mounted config files. Traefik in particular reads its static config (traefik_config.yml — plugins, entry points, access logs) only at startup; only dynamic_config.yml is hot-reloaded by the file provider. So after changing any rendered Traefik config, force-recreate it:

docker compose -f docker-compose.droplet.yml up -d --force-recreate traefik
# or, heavier (recreates the whole edge): make rebuild droplet

CrowdSec needs no manual bootstrap: the bouncer key is the same CROWDSEC_LAPI_KEY on both sides (registered on the container via BOUNCER_KEY_traefik, consumed by the Traefik plugin via crowdsecLapiKey). Its acquisition configs (pangolin/config/crowdsec/acquis.d/*.yaml) are committed static files — not templated — and its state (hub collections, decisions DB) lives in the crowdsec-config / crowdsec-data named volumes. On first make up droplet, Traefik downloads the bouncer plugin and CrowdSec pulls its collections, so give it a minute before testing. To confirm a real ban targets a real client IP, make logs droplet s=traefik should show your public IP as ClientHost in the JSON access log.

Commands

All commands take a host argument (droplet, phd-beelink, phd-tower, or phd-razer):

make up <host>              Start services
make down <host>            Stop services
make pull <host>            Pull latest images
make rebuild <host>         Clean rebuild all services
make logs <host>            View all logs
make logs <host> s=<svc>    View logs for one service
make ps <host>              Show running containers
make setup <host>           Create required directories
make clean <host>           Stop services and remove Docker resources
make sync                   Force-pull latest from git