- scripts/check-vps.sh: read-only reachability/ports/SSH-facts/DNS checker - README: real IPv4/IPv6 + Debian 13 + existing swap; firewall already on Vultr - DNS is managed at Cloudflare (NS delegated), not VentraIP — records must be DNS-only (grey cloud) so Caddy issues real certs and port 222 works - fix git remote to ssh:// form for the custom 222 port Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> |
||
|---|---|---|
| runner | ||
| scripts | ||
| .gitignore | ||
| compose.yml | ||
| README.md | ||
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.
The VPS (provisioned 2026-06-02)
Vultr shared-CPU 2 GB, Debian 13 (trixie), kernel 6.12, ~5.4 GB swap already present (no need to add any). Desktop SSH key installed; Vultr firewall groups applied on both IPv4 and IPv6.
| IPv4 | 45.32.242.27 |
| IPv6 | 2001:19f0:5801:1313:5400:6ff:fe35:c0a7 |
| SSH | root@45.32.242.27 (port 22) |
Pre-flight check
Before and after each bootstrap step, run the read-only checker from your desktop to confirm reachability, ports, the box's facts, and DNS:
./scripts/check-vps.sh --ip6 2001:19f0:5801:1313:5400:6ff:fe35:c0a7
It exits 0 when SSH + port 22 are good; closed app ports and unset DNS show as warnings (expected until DNS is updated and the stack is up).
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 — managed at Cloudflare, not VentraIP
VentraIP is only the registrar; the domain's nameservers are delegated to
Cloudflare (grace/keenan.ns.cloudflare.com), so create the records in the
Cloudflare dashboard. Point them at the VPS and wait for propagation:
A joshbairstow.com → 45.32.242.27A *.joshbairstow.com → 45.32.242.27(wildcard: covers blog/art/code/git + future)AAAA joshbairstow.com → 2001:19f0:5801:1313:5400:6ff:fe35:c0a7AAAA *.joshbairstow.com → 2001:19f0:5801:1313:5400:6ff:fe35:c0a7
Set these records to DNS-only (grey cloud), not proxied (orange cloud). The current records point at Cloudflare's proxy. With the proxy ON: Cloudflare terminates TLS with its own edge cert (so Caddy's automatic HTTPS is bypassed), and it only proxies HTTP(S) ports — git-over-SSH on 222 and the registry would break. DNS-only makes clients hit the VPS directly, so Caddy issues real Let's Encrypt certs via HTTP-01 (no DNS-challenge plugin needed) and all ports work. If you later want Cloudflare's proxy/CDN, switch Caddy to the DNS-01 challenge with the Cloudflare plugin + an API token — a separate change.
2. VPS base
Firewall is already handled by the Vultr firewall group (80, 443, 222, 22), so
host ufw is optional. Swap already exists. The remaining task is Docker:
# Install Docker Engine + compose plugin
curl -fsSL https://get.docker.com | sh
docker --version && docker compose version # confirm
(Re-run ./scripts/check-vps.sh — DOCKER should now report a version.)
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(45.32.242.27),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 (default branch is already `main`).
# Custom SSH port 222 needs the ssh:// URL form (scp-style host:port doesn't work):
git remote add origin ssh://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.