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) <noreply@anthropic.com>
This commit is contained in:
commit
76df80a26c
4 changed files with 274 additions and 0 deletions
3
.gitignore
vendored
Normal file
3
.gitignore
vendored
Normal file
|
|
@ -0,0 +1,3 @@
|
||||||
|
# Forgejo runner registration file — contains a secret, never commit.
|
||||||
|
runner/.runner
|
||||||
|
*.env
|
||||||
153
README.md
Normal file
153
README.md
Normal file
|
|
@ -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
|
||||||
|
└────────── <future apps> 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 → <VPS_IP>`
|
||||||
|
- `A *.joshbairstow.com → <VPS_IP>` (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 <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: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 <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`](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/<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:
|
||||||
|
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/<owner>/<app>: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.
|
||||||
|
```
|
||||||
80
compose.yml
Normal file
80
compose.yml
Normal file
|
|
@ -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:
|
||||||
38
runner/config.yml
Normal file
38
runner/config.yml
Normal file
|
|
@ -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 <REGISTRATION_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
|
||||||
Loading…
Add table
Add a link
Reference in a new issue