commit 76df80a26c134f66b768688181b3a82b6e92d6f4 Author: Josh Bairstow Date: Tue Jun 2 15:31:25 2026 +1000 chore: initial VPS edge stack (caddy-docker-proxy + Forgejo) 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) diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..7cdd3c1 --- /dev/null +++ b/.gitignore @@ -0,0 +1,3 @@ +# Forgejo runner registration file — contains a secret, never commit. +runner/.runner +*.env diff --git a/README.md b/README.md new file mode 100644 index 0000000..1eb7c1e --- /dev/null +++ b/README.md @@ -0,0 +1,153 @@ +# 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 + └────────── e.g. shop.joshbairstow.com +``` + +## Why this shape + +- **Edge proxy decouples projects.** Each project ships its own container with + `caddy` labels; 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 → ` +- `A *.joshbairstow.com → ` (wildcard covers blog/art/code/git + future) +- `AAAA` equivalents 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 +```sh +# 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: +```sh +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 +```sh +docker network create edge # MUST exist before `up` +git clone /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:package`** scope + (`Settings → Applications`). The automatic Actions token CANNOT push packages. + +### 5. Register the desktop runner +On the **home desktop** (has Docker + ssh): +```sh +# Get a registration token: Forgejo → Site Admin → Actions → Runners → Create +forgejo-runner register --no-interactive \ + --instance https://git.joshbairstow.com \ + --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`](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: +```sh +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 +```sh +# in the jb-website repo (rename master→main first; see that repo's README) +git remote add origin git@git.joshbairstow.com:222//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: +1. Ship a `compose.yml` on the VPS that joins the external `edge` network and + carries labels, e.g.: + ```yaml + services: + app: + image: git.joshbairstow.com//:latest + networks: [edge] + labels: + caddy: shop.joshbairstow.com + caddy.reverse_proxy: "{{upstreams 3000}}" + networks: + edge: + external: true + ``` +2. Give it its own `.forgejo/workflows/deploy.yml` (copy jb-website's). +3. `*.joshbairstow.com` already resolves, so the edge issues its cert + automatically on first hit. + +--- + +## Maintenance / hardening + +- **Backups:** the `forgejo_data` volume is now the source of truth (repos, DB, + registry blobs). Run `docker compose exec forgejo forgejo dump` on 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 `:sha` tags accumulate. Enable Forgejo's package + cleanup rules / run periodic GC so the VPS disk doesn't fill. +- **Certs:** never delete the `caddy_data` volume — losing it re-issues every + cert and risks Let's Encrypt rate limits. +- **Least privilege:** restrict the deploy key in `authorized_keys` with a + forced `command=` that only runs the compose pull/up script. +``` diff --git a/compose.yml b/compose.yml new file mode 100644 index 0000000..a6288f7 --- /dev/null +++ b/compose.yml @@ -0,0 +1,80 @@ +# ---------------------------------------------------------------------------- +# jb-infra — VPS edge stack: reverse proxy + self-hosted git/CI. +# +# Brought up MANUALLY on the VPS, once (it rarely changes). Everything else +# (jb-website and future projects) is deployed by CI and just joins the `edge` +# network + declares routing labels. +# +# docker network create edge # one time, BEFORE this stack +# docker compose up -d +# +# - caddy : owns 80/443, terminates TLS (automatic ACME) for every host, and +# builds its routing from Docker labels on containers attached to +# the `edge` network. +# - forgejo : git server + Actions + container registry at git.joshbairstow.com. +# ---------------------------------------------------------------------------- + +services: + caddy: + # Pin to the current release; verify the tag at + # https://hub.docker.com/r/lucaslorentz/caddy-docker-proxy/tags + image: lucaslorentz/caddy-docker-proxy:2.10-alpine + container_name: edge-caddy + restart: unless-stopped + ports: + - "80:80" + - "443:443" + - "443:443/udp" # HTTP/3 + environment: + # Only build routes from containers on this network. + CADDY_INGRESS_NETWORKS: edge + networks: + - edge + volumes: + - /var/run/docker.sock:/var/run/docker.sock:ro + - caddy_data:/data # certs live here — MUST persist (rate limits otherwise) + - caddy_config:/config + labels: + # ACME contact for every cert this proxy issues. + caddy.email: josh@joshbairstow.com + + forgejo: + # Pin to the current stable line; verify at + # https://codeberg.org/forgejo/-/packages/container/forgejo + image: codeberg.org/forgejo/forgejo:11 + container_name: forgejo + restart: unless-stopped + environment: + USER_UID: "1000" + USER_GID: "1000" + # Server identity — must be correct or registry login / clone URLs break. + FORGEJO__server__ROOT_URL: https://git.joshbairstow.com/ + FORGEJO__server__DOMAIN: git.joshbairstow.com + FORGEJO__server__HTTP_PORT: "3000" + FORGEJO__server__SSH_DOMAIN: git.joshbairstow.com + FORGEJO__server__SSH_PORT: "222" + # Enable Actions (CI). Runners register against this instance. + FORGEJO__actions__ENABLED: "true" + # SQLite keeps the footprint small on a modest VPS. + FORGEJO__database__DB_TYPE: sqlite3 + networks: + - edge + volumes: + - forgejo_data:/data + - /etc/timezone:/etc/timezone:ro + - /etc/localtime:/etc/localtime:ro + ports: + # git-over-SSH. 222 avoids the host's own sshd on 22. + - "222:22" + labels: + caddy: git.joshbairstow.com + caddy.reverse_proxy: "{{upstreams 3000}}" + +networks: + edge: + external: true + +volumes: + caddy_data: + caddy_config: + forgejo_data: diff --git a/runner/config.yml b/runner/config.yml new file mode 100644 index 0000000..f39eeda --- /dev/null +++ b/runner/config.yml @@ -0,0 +1,38 @@ +# ---------------------------------------------------------------------------- +# Forgejo Actions runner config — runs on the HOME DESKTOP (not the VPS). +# +# The desktop does the heavy `docker build` and pushes the image to the VPS +# registry, keeping the VPS light. Install the runner binary, then register and +# run it pointed at this file. See ../README.md for the full sequence. +# +# forgejo-runner register --no-interactive \ +# --instance https://git.joshbairstow.com \ +# --token \ +# --name desktop \ +# --labels desktop:host # ':host' = run jobs directly on this machine +# +# forgejo-runner daemon --config config.yml +# ---------------------------------------------------------------------------- + +log: + level: info + +runner: + # Path to the registration file produced by `register` (keep it out of git). + file: .runner + capacity: 1 + timeout: 30m + # `desktop:host` runs jobs directly on the desktop using its own Docker + + # ssh, which is what the workflow expects (`runs-on: desktop`). Host + # execution avoids mounting the docker socket into a job container. + labels: + - "desktop:host" + +container: + # Unused for host-label jobs, but harmless defaults if you later add + # docker-label runners for sandboxed jobs. + network: "" + privileged: false + +cache: + enabled: true