Edge reverse proxy (TLS + label routing) and self-hosted Forgejo (git + Actions + container registry) for joshbairstow.com, plus the desktop runner config and the one-time bootstrap runbook. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
6.2 KiB
jb-infra
VPS edge stack for joshbairstow.com: a caddy-docker-proxy edge reverse
proxy (TLS + label-based routing) and a self-hosted Forgejo git server with
CI and a container registry. Everything else — jb-website and future side
projects — is deployed by CI and just attaches to the edge network and
declares routing labels.
[home desktop] [Vultr VPS]
Forgejo runner (host exec) docker network: edge (external)
└ docker build ───push image──► ┌────────── edge-caddy :80/:443 (TLS, routing)
└ ssh deploy ───pull+up─────► ├────────── forgejo git.joshbairstow.com (+registry)
├────────── jb-website internal :80
└────────── <future apps> e.g. shop.joshbairstow.com
Why this shape
- Edge proxy decouples projects. Each project ships its own container with
caddylabels; the edge picks them up automatically. Adding a project needs zero edits here — no central config to touch. - Build off-box. The RAM-hungry Docker build runs on the always-on desktop runner; the VPS only pulls and runs prebuilt images, so it stays small.
- Self-hosted git/CI (Forgejo) — independent of GitHub/GitLab, with a cloud push-mirror kept only as backup.
Bootstrap (one-time, in order)
Order matters — TLS issuance needs DNS + open ports first, and the edge
network must exist before any stack references it.
1. DNS
Point at the VPS public IP, and wait for propagation before step 4:
A joshbairstow.com → <VPS_IP>A *.joshbairstow.com → <VPS_IP>(wildcard covers blog/art/code/git + future)AAAAequivalents if the VPS has IPv6.
A wildcard record means new subdomains resolve with no DNS change; certs are still issued per-host via HTTP-01, so no DNS-challenge plugin is required.
2. VPS base
# Install Docker Engine + compose plugin (official convenience script or distro pkg)
curl -fsSL https://get.docker.com | sh
# Firewall: allow only what we serve.
# 80, 443 -> edge proxy (HTTP/HTTPS, incl. ACME challenges)
# 222 -> Forgejo git-over-SSH
# 22 -> your normal host SSH (keep it!)
ufw allow 80,443/tcp && ufw allow 222/tcp && ufw allow OpenSSH && ufw enable
RAM: prefer the 2 GB Vultr tier. On 1 GB, add swap first:
fallocate -l 2G /swapfile && chmod 600 /swapfile && mkswap /swapfile && swapon /swapfile
echo '/swapfile none swap sw 0 0' >> /etc/fstab
3. Create the shared network + bring up the stack
docker network create edge # MUST exist before `up`
git clone <this repo> /opt/jb-infra && cd /opt/jb-infra
docker compose up -d
docker compose logs -f caddy # watch the first cert issuance
4. Configure Forgejo
Browse to https://git.joshbairstow.com (cert should be live):
- Complete first-run setup; create your admin user with a lowercase username (the registry path is case-sensitive and must be lowercase).
- Confirm Actions is enabled (
Site Administration → Actions). - Create a Personal Access Token with
write:packagescope (Settings → Applications). The automatic Actions token CANNOT push packages.
5. Register the desktop runner
On the home desktop (has Docker + ssh):
# Get a registration token: Forgejo → Site Admin → Actions → Runners → Create
forgejo-runner register --no-interactive \
--instance https://git.joshbairstow.com \
--token <REGISTRATION_TOKEN> \
--name desktop --labels desktop:host
forgejo-runner daemon --config /path/to/jb-infra/runner/config.yml
(For always-on, wrap the daemon in a systemd user service.) See
runner/config.yml.
6. Wire the jb-website repo
In the jb-website repo settings on Forgejo (Settings → Actions):
- Variables:
REGISTRY_OWNER= your lowercase owner. - Secrets:
REGISTRY_USER,REGISTRY_TOKEN(the PAT),DEPLOY_HOST,DEPLOY_USER,DEPLOY_SSH_KEY.
On the VPS, prepare the deploy target:
mkdir -p /opt/jb-website
# copy jb-website/compose.yml here (the production one — pulls, no build)
docker login git.joshbairstow.com # one-time, so `compose pull` can read the package
# (or mark the jb-website package public in Forgejo to skip VPS login)
Create a restricted deploy user whose authorized_keys holds the public half
of DEPLOY_SSH_KEY.
7. First deploy
# in the jb-website repo (rename master→main first; see that repo's README)
git remote add origin git@git.joshbairstow.com:222/<owner>/jb-website.git
git push -u origin main
The push triggers .forgejo/workflows/deploy.yml: the desktop builds + pushes
the image, then SSHes to the VPS to pull && up -d. The edge issues certs for
the apex + subdomains on first request.
Adding another project later
No changes to this repo. In the new project:
- Ship a
compose.ymlon the VPS that joins the externaledgenetwork and carries labels, e.g.:services: app: image: git.joshbairstow.com/<owner>/<app>:latest networks: [edge] labels: caddy: shop.joshbairstow.com caddy.reverse_proxy: "{{upstreams 3000}}" networks: edge: external: true - Give it its own
.forgejo/workflows/deploy.yml(copy jb-website's). *.joshbairstow.comalready resolves, so the edge issues its cert automatically on first hit.
Maintenance / hardening
- Backups: the
forgejo_datavolume is now the source of truth (repos, DB, registry blobs). Rundocker compose exec forgejo forgejo dumpon a schedule, and set a push-mirror on each repo to a cloud host (GitLab/GitHub) for off-box git backup. - Registry GC: per-commit
:shatags accumulate. Enable Forgejo's package cleanup rules / run periodic GC so the VPS disk doesn't fill. - Certs: never delete the
caddy_datavolume — losing it re-issues every cert and risks Let's Encrypt rate limits. - Least privilege: restrict the deploy key in
authorized_keyswith a forcedcommand=that only runs the compose pull/up script.