diff --git a/.dockerignore b/.dockerignore index e5c3dda..65e5f5c 100644 --- a/.dockerignore +++ b/.dockerignore @@ -3,7 +3,10 @@ dist .astro .git .github +.forgejo docs/design-system +compose.yml +compose.dev.yml .env .env.* *.log diff --git a/.forgejo/workflows/deploy.yml b/.forgejo/workflows/deploy.yml new file mode 100644 index 0000000..dc8bf8b --- /dev/null +++ b/.forgejo/workflows/deploy.yml @@ -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 diff --git a/Caddyfile b/Caddyfile index 6e51226..5ef20b5 100644 --- a/Caddyfile +++ b/Caddyfile @@ -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): -# joshbairstow.com -> /srv/* (apex / home) -# blog.joshbairstow.com -> /srv/blog/* (writing archive + posts) -# art.joshbairstow.com -> /srv/art/* (generative art focus view) -# code.joshbairstow.com -> /srv/code/* (experiments & tools) +# This Caddy no longer terminates TLS or owns 80/443. The edge proxy +# (caddy-docker-proxy, see the infra repo) terminates HTTPS for every host and +# reverse-proxies plaintext to this container on :80, preserving the original +# Host header. We branch on that Host header to serve the right slice of the +# single static build: # -# Each block points at the same /srv directory but uses `root` with the path -# prefix appropriate to the subdomain. Caddy handles HTTPS via the automatic -# ACME / Let's Encrypt flow at the email address below. +# joshbairstow.com / www. -> /srv (apex / home) +# blog.joshbairstow.com -> /srv/blog (writing archive + posts) +# 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 -# with no TLS so `docker compose up` works out of the box on a laptop. +# Shared build assets (/_astro/*, /assets/*) live at the apex root, so the +# 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. - email josh@joshbairstow.com + # Edge proxy owns TLS; this server is plaintext-only and has no admin needs. + auto_https off + admin off } -# ---- apex ------------------------------------------------------------------ -joshbairstow.com, www.joshbairstow.com { - root * /srv +:80 { 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 redir @www https://joshbairstow.com{uri} permanent -} -# ---- blog ------------------------------------------------------------------ -blog.joshbairstow.com { - root * /srv/blog - 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 { + # ---- apex -------------------------------------------------------------- + @apex host joshbairstow.com + handle @apex { root * /srv + try_files {path} {path}/index.html /404.html file_server } - handle { - file_server - try_files {path} {path}/index.html /srv/404.html - } -} -# ---- art ------------------------------------------------------------------- -art.joshbairstow.com { - root * /srv/art - encode zstd gzip - @asset path /_astro/* /assets/* - handle @asset { + # ---- blog -------------------------------------------------------------- + @blog host blog.joshbairstow.com + handle @blog { + @asset path /_astro/* /assets/* + 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 + try_files {path} {path}/index.html /404.html 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 } diff --git a/Dockerfile b/Dockerfile index 6117b22..15a6456 100644 --- a/Dockerfile +++ b/Dockerfile @@ -20,5 +20,6 @@ FROM caddy:2-alpine COPY Caddyfile /etc/caddy/Caddyfile COPY --from=build /app/dist /srv -# Caddy listens on 80/443; the host's reverse proxy or docker compose maps these. -EXPOSE 80 443 +# Internal plaintext server only — the edge reverse proxy terminates TLS and +# forwards to this port. No 443 here. +EXPOSE 80 diff --git a/README.md b/README.md index e755e8c..e5bf4e3 100644 --- a/README.md +++ b/README.md @@ -45,24 +45,48 @@ npm run build # outputs static site to dist/ 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 -docker compose build -docker compose up -d +docker compose -f compose.dev.yml up --build # http://localhost:8080 ``` -Caddy's site config (`Caddyfile`) maps host headers → path prefixes: -- `joshbairstow.com` → `/srv/` +`localhost:8080` falls through to the apex build; subdomain Host-matching only +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/`  (`www.` → 301 apex) - `blog.joshbairstow.com` → `/srv/blog/` - `art.joshbairstow.com` → `/srv/art/` - `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 diff --git a/compose.dev.yml b/compose.dev.yml new file mode 100644 index 0000000..d4e6ac9 --- /dev/null +++ b/compose.dev.yml @@ -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" diff --git a/compose.yml b/compose.yml index 8e548ad..db063dc 100644 --- a/compose.yml +++ b/compose.yml @@ -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 -# ACME flow at the email configured in Caddyfile. Side-hustle container -# services (signage, keycaps, etc.) would slot in alongside this one — Caddy -# in this container can proxy to them, or you can put a dedicated Caddy in -# front of all containers. +# This file does NOT build. The image is built on the home-desktop CI runner +# and pushed to the Forgejo container registry; the VPS only pulls and runs it. +# The deploy step (see .forgejo/workflows/deploy.yml) does: +# +# TAG= docker compose -f /opt/jb-website/compose.yml pull +# TAG= 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: web: - build: . - image: jb-website:latest + # REGISTRY_OWNER must be the lowercase Forgejo owner (user/org). TAG is the + # 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 restart: unless-stopped - ports: - - "80:80" - - "443:443" - volumes: - - caddy_data:/data - - caddy_config:/config + networks: + - edge + labels: + caddy: joshbairstow.com, www.joshbairstow.com, blog.joshbairstow.com, art.joshbairstow.com, code.joshbairstow.com + caddy.reverse_proxy: "{{upstreams 80}}" -volumes: - caddy_data: - caddy_config: +networks: + edge: + external: true diff --git a/docs/planning.md b/docs/planning.md index ff6c79b..ae82d1c 100644 --- a/docs/planning.md +++ b/docs/planning.md @@ -170,9 +170,8 @@ Recommendation for a project of this size: **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. - **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: - - Build locally → push image to a registry (GHCR or Docker Hub) → SSH to VPS → `compose pull && up -d`. Fully manual, very transparent. - - 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. +- **Deployment** (decided 2026-06-02 — see §6.5): + - 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). ### 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). - **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 | 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 | 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-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