Compare commits

..

No commits in common. "085e0bb3b6d4870fa86060495d9712c3b061fb0b" and "5c9d4a99cce0603e3055b22b3faded7bf4cb70bf" have entirely different histories.

8 changed files with 98 additions and 262 deletions

View file

@ -3,10 +3,7 @@ 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

@ -1,64 +0,0 @@
# ----------------------------------------------------------------------------
# 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

146
Caddyfile
View file

@ -1,99 +1,89 @@
# ---------------------------------------------------------------------------- # ----------------------------------------------------------------------------
# jb-website — INTERNAL static server (behind the edge reverse proxy). # jb-website — Caddy site config.
# #
# This Caddy no longer terminates TLS or owns 80/443. The edge proxy # Routing model (see docs/planning.md §6.2):
# (caddy-docker-proxy, see the infra repo) terminates HTTPS for every host and # joshbairstow.com -> /srv/* (apex / home)
# reverse-proxies plaintext to this container on :80, preserving the original # blog.joshbairstow.com -> /srv/blog/* (writing archive + posts)
# Host header. We branch on that Host header to serve the right slice of the # art.joshbairstow.com -> /srv/art/* (generative art focus view)
# single static build: # code.joshbairstow.com -> /srv/code/* (experiments & tools)
# #
# joshbairstow.com / www. -> /srv (apex / home) # Each block points at the same /srv directory but uses `root` with the path
# blog.joshbairstow.com -> /srv/blog (writing archive + posts) # prefix appropriate to the subdomain. Caddy handles HTTPS via the automatic
# art.joshbairstow.com -> /srv/art (generative art focus view) # ACME / Let's Encrypt flow at the email address below.
# code.joshbairstow.com -> /srv/code (experiments & tools)
# #
# Shared build assets (/_astro/*, /assets/*) live at the apex root, so the # Local dev: see the `localhost` block at the bottom — Caddy listens on :8080
# subdomain handlers re-root those paths back to /srv. # with no TLS so `docker compose up` works out of the box on a laptop.
#
# 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.
# ---------------------------------------------------------------------------- # ----------------------------------------------------------------------------
{ {
# Edge proxy owns TLS; this server is plaintext-only and has no admin needs. # Replace with the address you want ACME-challenge contact / cert-expiry mail at.
auto_https off email josh@joshbairstow.com
admin off
} }
:80 { # ---- apex ------------------------------------------------------------------
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
}
# ---- apex -------------------------------------------------------------- # ---- blog ------------------------------------------------------------------
@apex host joshbairstow.com blog.joshbairstow.com {
handle @apex { 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 {
root * /srv root * /srv
try_files {path} {path}/index.html /404.html
file_server file_server
} }
# ---- 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 { handle {
root * /srv
try_files {path} {path}/index.html /404.html
file_server 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 {
root * /srv
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,6 +20,5 @@ 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
# Internal plaintext server only — the edge reverse proxy terminates TLS and # Caddy listens on 80/443; the host's reverse proxy or docker compose maps these.
# forwards to this port. No 443 here. EXPOSE 80 443
EXPOSE 80

View file

@ -45,48 +45,24 @@ npm run build # outputs static site to dist/
npm run preview # serve the build locally npm run preview # serve the build locally
``` ```
## Local container preview ## Deploy (containerised, target = Vultr Sydney VPS)
`npm run dev` is the primary local loop. To eyeball the *built container* (real The repo ships a Caddy-fronted container that serves the static build directly with automatic HTTPS.
Caddy + static output) without the production edge proxy:
```sh ```sh
docker compose -f compose.dev.yml up --build # http://localhost:8080 docker compose build
docker compose up -d
``` ```
`localhost:8080` falls through to the apex build; subdomain Host-matching only Caddy's site config (`Caddyfile`) maps host headers → path prefixes:
fires for the real hostnames (use `npm run dev`, or hosts-file entries, to - `joshbairstow.com``/srv/`
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/` - `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/`
Shared assets (`/_astro/*`, `/assets/*`) are re-rooted back to `/srv` so they 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.
resolve from any subdomain.
**Pipeline** ([`.forgejo/workflows/deploy.yml`](.forgejo/workflows/deploy.yml)): To add another in-repo subdomain later, add a directory under `src/pages/` and a matching block in the Caddyfile — that's it.
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

View file

@ -1,25 +0,0 @@
# ----------------------------------------------------------------------------
# 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,35 +1,26 @@
# ---------------------------------------------------------------------------- # ----------------------------------------------------------------------------
# jb-website — PRODUCTION compose (lives on the VPS at /opt/jb-website/). # jb-website — production compose file.
# #
# This file does NOT build. The image is built on the home-desktop CI runner # Runs the Caddy-fronted static site. Caddy handles HTTPS via the automatic
# and pushed to the Forgejo container registry; the VPS only pulls and runs it. # ACME flow at the email configured in Caddyfile. Side-hustle container
# The deploy step (see .forgejo/workflows/deploy.yml) does: # 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
# TAG=<commit-sha> docker compose -f /opt/jb-website/compose.yml pull # front of all containers.
# 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:
# REGISTRY_OWNER must be the lowercase Forgejo owner (user/org). TAG is the build: .
# commit sha set by the deploy step; falls back to latest for manual `up`. image: jb-website:latest
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
networks: ports:
- edge - "80:80"
labels: - "443:443"
caddy: joshbairstow.com, www.joshbairstow.com, blog.joshbairstow.com, art.joshbairstow.com, code.joshbairstow.com volumes:
caddy.reverse_proxy: "{{upstreams 80}}" - caddy_data:/data
- caddy_config:/config
networks: volumes:
edge: caddy_data:
external: true caddy_config:

View file

@ -170,8 +170,9 @@ 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** (decided 2026-06-02 — see §6.5): - **Deployment** options:
- 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). - 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.
### 6.4 Other open questions ### 6.4 Other open questions
@ -181,32 +182,6 @@ 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 |
@ -221,9 +196,6 @@ offloading builds keeps the VPS on the cheap tier. RAM target bumped to 2 GB (or
| 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