Sanitised layout of a small self-hosted platform I run for side projects and internal tools. One Linux host. No public ingress. CI deploys over Tailscale. Prometheus and Grafana watch the lot.
Hostnames, IPs, tokens, and service names are scrubbed. The shape is real.
- How it works → docs/ARCHITECTURE.md
- How to run it → docs/OPERATIONS.md
Most self-hosted writeups stop at docker compose up. The interesting
bit is the wrapper around it: keeping the host off the public internet,
shipping changes from CI without SSH'ing in by hand, and having enough
signal to know what's broken without logging in.
This repo documents the wrapper, not the apps.
Engineers who want to run their own services on one host without
reinventing the production wrapper around docker compose up. Clone it,
strip what doesn't fit, replace the placeholders, keep the parts that do.
Not a framework. A reference you can steal from.
- Cloudflare DNS + Tunnel — public ingress without open ports on the host.
- Traefik v3 — host-header routing to Compose-managed containers.
- Docker Compose — one stack per service; shared Postgres + Redis.
- GitHub Actions over Tailscale SSH — deploys without a public SSH port.
- Prometheus + Grafana — metrics, dashboards, alerts.
Prereqs: a Linux host with a public IP, a Cloudflare zone, a Tailscale tailnet, and a GitHub org with GHCR.
Recurring commands are wrapped in the Makefile — make help lists them.
- Provision the host. Install Docker.
ufwdefault-deny on the public interface.tailscaledjoins the tailnet. - Create a Cloudflare Tunnel (
cloudflared tunnel create platform). Drop the token indocker/traefik/.envasCLOUDFLARED_TUNNEL_TOKEN. - Set Cloudflare account/zone/tunnel IDs in
terraform/cloudflare/variables.tf. Runmake apply. - Bring up the platform:
make up. - Add a service — see Adding a service.
A service is described by a small manifest:
# examples/example-app.yml
name: example-app
domain: app.example.com
image: ghcr.io/example-org/example-app:abc123def456
port: 8080
stack: example-app
healthcheck:
path: /health
interval: 10s
routing:
internal: false
middlewares: [secure-headers, rate-limit-default, compress]Render it to Traefik dynamic config:
make render MANIFEST=examples/example-app.ymlFull walkthrough in Adding a service.
Makefile common targets (`make help`)
docs/ ARCHITECTURE.md, OPERATIONS.md
diagrams/ mermaid sources
terraform/ Cloudflare DNS + tunnel routes
docker/ compose stacks for traefik and monitoring
github-actions/ example deploy workflow
examples/ service manifests
scripts/ deploy.sh, render-service.sh
Public repo, so:
- Real domain names, hostnames, tailnet names.
- Tunnel tokens, Cloudflare API tokens, GHCR PATs, SSH keys.
- Real public/tailnet IPs.
- The actual list of services and what they do.
- Customer data, internal tool names, prod credentials.
Where you see example.com, REPLACE_ME, or xxxxxxxxxxxx, that's a
placeholder.
v1. One host. No Kubernetes, no service mesh, no multi-region. Those are on the "if I actually need them" list.