Compare commits

..

2 commits

Author SHA1 Message Date
085e0bb3b6 fix(deploy): default REGISTRY_OWNER to joshbairstow (actual Forgejo owner)
Some checks failed
deploy / build-and-deploy (push) Failing after 13s
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-02 23:46:56 +10:00
7ecd1b32ae feat(deploy): self-hosted CI/CD + dedicated edge-proxy topology
Refactor the site container from a TLS-owning, port-binding proxy into an
internal plaintext static server that sits behind a dedicated edge reverse
proxy, and add an automated build/deploy pipeline.

- Caddyfile: single :80 listener (auto_https off), Host-matcher routing for
  apex/blog/art/code with /_astro+/assets re-root and www->apex redirect
- Dockerfile: EXPOSE 80 only (TLS now handled by the edge proxy)
- compose.yml: pull registry image, join external 'edge' network, declare
  routing via caddy-docker-proxy labels; drop build/ports/cert volumes
- compose.dev.yml: local container preview on :8080
- .forgejo/workflows/deploy.yml: push-to-main -> desktop runner builds, pushes
  to the Forgejo registry, SSH-deploys to the VPS
- docs + .dockerignore updated for the new topology

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-02 15:31:16 +10:00
8 changed files with 261 additions and 97 deletions

View file

@ -3,7 +3,10 @@ dist
.astro .astro
.git .git
.github .github
.forgejo
docs/design-system docs/design-system
compose.yml
compose.dev.yml
.env .env
.env.* .env.*
*.log *.log

View file

@ -0,0 +1,64 @@
# ----------------------------------------------------------------------------
# Build + deploy jb-website.
#
# Runs on the home-desktop host runner (label `desktop`): builds the image,
# pushes it to the Forgejo container registry, then SSHes to the VPS to pull
# and restart. The VPS never builds — it only runs the prebuilt image.
#
# Required Forgejo settings (Repo > Settings > Actions):
# Variables:
# REGISTRY_OWNER lowercase Forgejo user/org that owns the package
# Secrets:
# REGISTRY_USER Forgejo username for `docker login`
# REGISTRY_TOKEN Forgejo PAT with write:package scope
# DEPLOY_HOST VPS host/IP
# DEPLOY_USER SSH user on the VPS (deploy account)
# DEPLOY_SSH_KEY private key whose public half is in the VPS authorized_keys
# ----------------------------------------------------------------------------
name: deploy
on:
push:
branches: [main]
env:
REGISTRY: git.joshbairstow.com
IMAGE_NAME: jb-website
jobs:
build-and-deploy:
runs-on: desktop
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Resolve image reference
run: |
IMAGE="${REGISTRY}/${{ vars.REGISTRY_OWNER }}/${IMAGE_NAME}"
echo "IMAGE=${IMAGE}" >> "$GITHUB_ENV"
echo "Building ${IMAGE}:${{ github.sha }}"
- name: Log in to the Forgejo registry
run: |
echo "${{ secrets.REGISTRY_TOKEN }}" \
| docker login "${REGISTRY}" -u "${{ secrets.REGISTRY_USER }}" --password-stdin
- name: Build and push image
run: |
docker build -t "${IMAGE}:${{ github.sha }}" -t "${IMAGE}:latest" .
docker push "${IMAGE}:${{ github.sha }}"
docker push "${IMAGE}:latest"
- name: Deploy to VPS over SSH
run: |
install -m 600 /dev/null deploy_key
printf '%s\n' "${{ secrets.DEPLOY_SSH_KEY }}" > deploy_key
ssh -i deploy_key -o StrictHostKeyChecking=accept-new \
"${{ secrets.DEPLOY_USER }}@${{ secrets.DEPLOY_HOST }}" \
"export REGISTRY_OWNER='${{ vars.REGISTRY_OWNER }}' TAG='${{ github.sha }}'; \
cd /opt/jb-website && \
docker compose -f compose.yml pull && \
docker compose -f compose.yml up -d && \
docker image prune -f"
rm -f deploy_key

144
Caddyfile
View file

@ -1,89 +1,99 @@
# ---------------------------------------------------------------------------- # ----------------------------------------------------------------------------
# jb-website — Caddy site config. # jb-website — INTERNAL static server (behind the edge reverse proxy).
# #
# Routing model (see docs/planning.md §6.2): # This Caddy no longer terminates TLS or owns 80/443. The edge proxy
# joshbairstow.com -> /srv/* (apex / home) # (caddy-docker-proxy, see the infra repo) terminates HTTPS for every host and
# blog.joshbairstow.com -> /srv/blog/* (writing archive + posts) # reverse-proxies plaintext to this container on :80, preserving the original
# art.joshbairstow.com -> /srv/art/* (generative art focus view) # Host header. We branch on that Host header to serve the right slice of the
# code.joshbairstow.com -> /srv/code/* (experiments & tools) # single static build:
# #
# Each block points at the same /srv directory but uses `root` with the path # joshbairstow.com / www. -> /srv (apex / home)
# prefix appropriate to the subdomain. Caddy handles HTTPS via the automatic # blog.joshbairstow.com -> /srv/blog (writing archive + posts)
# ACME / Let's Encrypt flow at the email address below. # art.joshbairstow.com -> /srv/art (generative art focus view)
# code.joshbairstow.com -> /srv/code (experiments & tools)
# #
# Local dev: see the `localhost` block at the bottom — Caddy listens on :8080 # Shared build assets (/_astro/*, /assets/*) live at the apex root, so the
# with no TLS so `docker compose up` works out of the box on a laptop. # subdomain handlers re-root those paths back to /srv.
#
# One bare `:80` listener + host matchers (NOT named-host site blocks) keeps
# this purely plaintext: named-host blocks would re-trigger Caddy's automatic
# HTTPS, which the edge proxy already handles. Local preview is provided by
# compose.dev.yml mapping a host port to this :80.
# ---------------------------------------------------------------------------- # ----------------------------------------------------------------------------
{ {
# Replace with the address you want ACME-challenge contact / cert-expiry mail at. # Edge proxy owns TLS; this server is plaintext-only and has no admin needs.
email josh@joshbairstow.com auto_https off
admin off
} }
# ---- apex ------------------------------------------------------------------ :80 {
joshbairstow.com, www.joshbairstow.com {
root * /srv
encode zstd gzip encode zstd gzip
file_server
try_files {path} {path}/index.html /404.html
# ---- www -> apex redirect ----------------------------------------------
# The upstream only ever sees http, so the scheme is hardcoded https.
@www host www.joshbairstow.com @www host www.joshbairstow.com
redir @www https://joshbairstow.com{uri} permanent redir @www https://joshbairstow.com{uri} permanent
}
# ---- blog ------------------------------------------------------------------ # ---- apex --------------------------------------------------------------
blog.joshbairstow.com { @apex host joshbairstow.com
root * /srv/blog handle @apex {
encode zstd gzip
# Assets and the canvas-island JS live at /_astro and /assets on the apex build.
# Re-route them from the subdomain root by falling through to the parent /srv.
@asset path /_astro/* /assets/*
handle @asset {
root * /srv root * /srv
try_files {path} {path}/index.html /404.html
file_server file_server
} }
handle {
file_server
try_files {path} {path}/index.html /srv/404.html
}
}
# ---- art ------------------------------------------------------------------- # ---- blog --------------------------------------------------------------
art.joshbairstow.com { @blog host blog.joshbairstow.com
root * /srv/art handle @blog {
encode zstd gzip @asset path /_astro/* /assets/*
@asset path /_astro/* /assets/* handle @asset {
handle @asset { root * /srv
file_server
}
handle {
root * /srv/blog
try_files {path} {path}/index.html /srv/404.html
file_server
}
}
# ---- art ---------------------------------------------------------------
@art host art.joshbairstow.com
handle @art {
@asset path /_astro/* /assets/*
handle @asset {
root * /srv
file_server
}
handle {
root * /srv/art
try_files {path} {path}/index.html /srv/404.html
file_server
}
}
# ---- code --------------------------------------------------------------
@code host code.joshbairstow.com
handle @code {
@asset path /_astro/* /assets/*
handle @asset {
root * /srv
file_server
}
handle {
root * /srv/code
try_files {path} {path}/index.html /srv/404.html
file_server
}
}
# ---- fallback ----------------------------------------------------------
# Any other Host (e.g. hitting the container directly, or local preview at
# localhost:8080) serves the apex build so the site is never blank.
handle {
root * /srv root * /srv
try_files {path} {path}/index.html /404.html
file_server file_server
} }
handle {
file_server
try_files {path} {path}/index.html /srv/404.html
}
}
# ---- code ------------------------------------------------------------------
code.joshbairstow.com {
root * /srv/code
encode zstd gzip
@asset path /_astro/* /assets/*
handle @asset {
root * /srv
file_server
}
handle {
file_server
try_files {path} {path}/index.html /srv/404.html
}
}
# ---- local dev (no TLS) ----------------------------------------------------
# Hit http://localhost:8080 to preview the built apex; the subdomain rewrites
# above only fire when accessed by their real hostnames.
:8080 {
root * /srv
encode zstd gzip
file_server
try_files {path} {path}/index.html /404.html
} }

View file

@ -20,5 +20,6 @@ FROM caddy:2-alpine
COPY Caddyfile /etc/caddy/Caddyfile COPY Caddyfile /etc/caddy/Caddyfile
COPY --from=build /app/dist /srv COPY --from=build /app/dist /srv
# Caddy listens on 80/443; the host's reverse proxy or docker compose maps these. # Internal plaintext server only — the edge reverse proxy terminates TLS and
EXPOSE 80 443 # forwards to this port. No 443 here.
EXPOSE 80

View file

@ -45,24 +45,48 @@ npm run build # outputs static site to dist/
npm run preview # serve the build locally npm run preview # serve the build locally
``` ```
## Deploy (containerised, target = Vultr Sydney VPS) ## Local container preview
The repo ships a Caddy-fronted container that serves the static build directly with automatic HTTPS. `npm run dev` is the primary local loop. To eyeball the *built container* (real
Caddy + static output) without the production edge proxy:
```sh ```sh
docker compose build docker compose -f compose.dev.yml up --build # http://localhost:8080
docker compose up -d
``` ```
Caddy's site config (`Caddyfile`) maps host headers → path prefixes: `localhost:8080` falls through to the apex build; subdomain Host-matching only
- `joshbairstow.com``/srv/` fires for the real hostnames (use `npm run dev`, or hosts-file entries, to
exercise cross-subdomain routing locally).
## Deploy (CI/CD → Vultr VPS behind a shared edge proxy)
Deployment is automated. The site no longer terminates its own TLS — a
dedicated **caddy-docker-proxy** edge container (in the separate
[`jb-infra`](../jb-infra) repo) owns 80/443, does automatic HTTPS for every
host, and routes to this container over the shared `edge` Docker network using
the labels in [`compose.yml`](compose.yml). This container is now an internal
plaintext static server that still maps host → path prefix in its `Caddyfile`:
- `joshbairstow.com``/srv/` &nbsp;(`www.` → 301 apex)
- `blog.joshbairstow.com``/srv/blog/` - `blog.joshbairstow.com``/srv/blog/`
- `art.joshbairstow.com``/srv/art/` - `art.joshbairstow.com``/srv/art/`
- `code.joshbairstow.com``/srv/code/` - `code.joshbairstow.com``/srv/code/`
Asset paths (`/_astro/*` and `/assets/*`) are re-rooted back to `/srv` from the subdomain blocks so they resolve regardless of which subdomain served the HTML. Shared assets (`/_astro/*`, `/assets/*`) are re-rooted back to `/srv` so they
resolve from any subdomain.
To add another in-repo subdomain later, add a directory under `src/pages/` and a matching block in the Caddyfile — that's it. **Pipeline** ([`.forgejo/workflows/deploy.yml`](.forgejo/workflows/deploy.yml)):
push to `main` → the home-desktop Forgejo Actions runner builds the image,
pushes it to the Forgejo container registry (`git.joshbairstow.com`), then SSHes
to the VPS to `docker compose pull && up -d`. The VPS never builds.
See [`jb-infra/README.md`](../jb-infra/README.md) for the one-time bootstrap
(DNS, edge proxy + Forgejo, runner registration, secrets) and the required
Forgejo variables/secrets.
To add another in-repo subdomain: add a directory under `src/pages/`, a `@host`
matcher block in the `Caddyfile`, and the hostname to the `caddy` label in
`compose.yml`.
## Writing a blog post ## Writing a blog post

25
compose.dev.yml Normal file
View file

@ -0,0 +1,25 @@
# ----------------------------------------------------------------------------
# jb-website — LOCAL preview of the built container.
#
# The production compose.yml pulls a registry image, joins the external `edge`
# network, and publishes no ports — none of which works on a laptop. This file
# builds the image locally and maps a host port straight to the internal :80 so
# you can eyeball the real Caddy/static output without the edge proxy.
#
# docker compose -f compose.dev.yml up --build
# -> http://localhost:8080
#
# Subdomain Host-matching only fires for the real hostnames, so localhost:8080
# falls through to the apex build (see the fallback handler in Caddyfile). To
# exercise subdomain routing locally, add hosts-file entries pointing the real
# names at 127.0.0.1, or just use `npm run dev` (the urls.ts helpers collapse
# cross-subdomain links to relative paths in dev).
# ----------------------------------------------------------------------------
services:
web:
build: .
image: jb-website:dev
container_name: jb-website-dev
ports:
- "8080:80"

View file

@ -1,26 +1,35 @@
# ---------------------------------------------------------------------------- # ----------------------------------------------------------------------------
# jb-website — production compose file. # jb-website — PRODUCTION compose (lives on the VPS at /opt/jb-website/).
# #
# Runs the Caddy-fronted static site. Caddy handles HTTPS via the automatic # This file does NOT build. The image is built on the home-desktop CI runner
# ACME flow at the email configured in Caddyfile. Side-hustle container # and pushed to the Forgejo container registry; the VPS only pulls and runs it.
# services (signage, keycaps, etc.) would slot in alongside this one — Caddy # The deploy step (see .forgejo/workflows/deploy.yml) does:
# in this container can proxy to them, or you can put a dedicated Caddy in #
# front of all containers. # TAG=<commit-sha> docker compose -f /opt/jb-website/compose.yml pull
# TAG=<commit-sha> docker compose -f /opt/jb-website/compose.yml up -d
#
# Routing + TLS are handled by the edge proxy (caddy-docker-proxy, infra repo).
# This service is internal-only: no published ports, no certs. It joins the
# shared external `edge` network and declares its hostnames via labels; the edge
# proxy discovers them and reverse-proxies (plaintext, Host preserved) to :80.
#
# For local preview use compose.dev.yml instead (this file won't run off-VPS
# because the `edge` network is external).
# ---------------------------------------------------------------------------- # ----------------------------------------------------------------------------
services: services:
web: web:
build: . # REGISTRY_OWNER must be the lowercase Forgejo owner (user/org). TAG is the
image: jb-website:latest # commit sha set by the deploy step; falls back to latest for manual `up`.
image: git.joshbairstow.com/${REGISTRY_OWNER:-joshbairstow}/jb-website:${TAG:-latest}
container_name: jb-website container_name: jb-website
restart: unless-stopped restart: unless-stopped
ports: networks:
- "80:80" - edge
- "443:443" labels:
volumes: caddy: joshbairstow.com, www.joshbairstow.com, blog.joshbairstow.com, art.joshbairstow.com, code.joshbairstow.com
- caddy_data:/data caddy.reverse_proxy: "{{upstreams 80}}"
- caddy_config:/config
volumes: networks:
caddy_data: edge:
caddy_config: external: true

View file

@ -170,9 +170,8 @@ Recommendation for a project of this size:
**Suggested stack on the VPS:** **Suggested stack on the VPS:**
- **Caddy** as the reverse proxy — automatic HTTPS via Let's Encrypt, dead-simple Caddyfile, handles the host-header → path-prefix routing cleanly. - **Caddy** as the reverse proxy — automatic HTTPS via Let's Encrypt, dead-simple Caddyfile, handles the host-header → path-prefix routing cleanly.
- **Docker + docker compose** — the Astro static output baked into a tiny container (Caddy + the `dist/` files, or just nginx-alpine + static files); side-hustle containers added alongside under the same compose file. - **Docker + docker compose** — the Astro static output baked into a tiny container (Caddy + the `dist/` files, or just nginx-alpine + static files); side-hustle containers added alongside under the same compose file.
- **Deployment** options: - **Deployment** (decided 2026-06-02 — see §6.5):
- Build locally → push image to a registry (GHCR or Docker Hub) → SSH to VPS → `compose pull && up -d`. Fully manual, very transparent. - Self-hosted **Forgejo** on the VPS (`git.joshbairstow.com`) hosts the repo + CI + a container registry; a **Forgejo Actions runner on the home desktop** builds the image and SSH-deploys to the VPS. Earlier GHCR/GitHub-Actions options were dropped in favour of self-hosting (learning value + avoids GitHub).
- Or GitHub Actions: build + push image on tag, then a webhook or SSH step triggers the VPS pull. Slightly more setup, far less ceremony per deploy. Recommended once the workflow is steady.
### 6.4 Other open questions ### 6.4 Other open questions
@ -182,6 +181,32 @@ Recommendation for a project of this size:
- **`code.` subdomain content:** ~~is this also served from this repo~~ — confirmed in-repo (2026-06-01). - **`code.` subdomain content:** ~~is this also served from this repo~~ — confirmed in-repo (2026-06-01).
- **Design system docs source:** ~~URL 404'd~~ — imported successfully (2026-06-01); see `docs/design-system/`. - **Design system docs source:** ~~URL 404'd~~ — imported successfully (2026-06-01); see `docs/design-system/`.
### 6.5 CI/CD + multi-project topology (decided 2026-06-02)
The site is one project among several that will eventually share the VPS via
subdomains (separate repos/containers each). To keep their lifecycles
independent, infrastructure splits in two:
- **`jb-infra` repo (bootstrapped manually on the VPS):** a dedicated
**caddy-docker-proxy** edge container owns 80/443, terminates TLS for all
hosts, and routes by **Docker labels** over a shared external `edge` network;
plus **Forgejo** (`git.joshbairstow.com`) for self-hosted git + Actions + a
container registry.
- **This repo:** refactored to an *internal* plaintext static server (drops
TLS/ACME/port-binding; keeps host→path + asset re-root in `Caddyfile`), joins
the `edge` network, and declares its hostnames via `caddy` labels in
`compose.yml`. CI lives in `.forgejo/workflows/deploy.yml`.
**Flow:** push to `main` → home-desktop runner builds the image → pushes to the
Forgejo registry → SSHes to the VPS → `compose pull && up -d`. The small VPS
never builds (build offloaded to the always-on desktop). New projects plug in by
joining `edge` + adding labels — no central config edits.
Rationale: self-hosting Forgejo is a deliberate learning investment and avoids
GitHub; the label-driven edge proxy scales to many independent repos cleanly;
offloading builds keeps the VPS on the cheap tier. RAM target bumped to 2 GB (or
1 GB + swap). See `jb-infra/README.md` for the full bootstrap runbook.
## 7. Decisions log ## 7. Decisions log
| Date | Decision | Rationale | | Date | Decision | Rationale |
@ -196,6 +221,9 @@ Recommendation for a project of this size:
| 2026-06-01 | `code.` subdomain is in-repo (third in-repo section alongside `blog.` and `art.`) | User confirmed; routing model already supports it | | 2026-06-01 | `code.` subdomain is in-repo (third in-repo section alongside `blog.` and `art.`) | User confirmed; routing model already supports it |
| 2026-06-01 | Architecture must remain open to adding further in-repo subdomain paths later | User flagged this as a constraint; host-aware routing in §6.2 scales to N paths without rework | | 2026-06-01 | Architecture must remain open to adding further in-repo subdomain paths later | User flagged this as a constraint; host-aware routing in §6.2 scales to N paths without rework |
| 2026-06-01 | Brand-system docs imported to `docs/design-system/` from a fresh handoff bundle | Establishes the canonical brand spec in-repo so future work can cite it | | 2026-06-01 | Brand-system docs imported to `docs/design-system/` from a fresh handoff bundle | Establishes the canonical brand spec in-repo so future work can cite it |
| 2026-06-02 | Self-host git + CI via Forgejo on the VPS (`git.joshbairstow.com`); runner on the home desktop | Learning value + avoids GitHub availability issues; offloads builds from the small VPS |
| 2026-06-02 | Dedicated `caddy-docker-proxy` edge container owns TLS/routing via Docker labels; website becomes an internal server | Decouples each project's deploy; new subdomains/containers plug in with zero central edits |
| 2026-06-02 | Infra (edge proxy + Forgejo) split into a separate `jb-infra` repo, bootstrapped manually | Infra changes rarely; keeps the app repo's CI focused on the app |
## 8. Next steps ## 8. Next steps