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>
This commit is contained in:
parent
5c9d4a99cc
commit
7ecd1b32ae
8 changed files with 261 additions and 97 deletions
|
|
@ -3,7 +3,10 @@ dist
|
|||
.astro
|
||||
.git
|
||||
.github
|
||||
.forgejo
|
||||
docs/design-system
|
||||
compose.yml
|
||||
compose.dev.yml
|
||||
.env
|
||||
.env.*
|
||||
*.log
|
||||
|
|
|
|||
64
.forgejo/workflows/deploy.yml
Normal file
64
.forgejo/workflows/deploy.yml
Normal 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
|
||||
154
Caddyfile
154
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
|
||||
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 {
|
||||
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
|
||||
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 {
|
||||
root * /srv
|
||||
try_files {path} {path}/index.html /404.html
|
||||
file_server
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
40
README.md
40
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
|
||||
|
||||
|
|
|
|||
25
compose.dev.yml
Normal file
25
compose.dev.yml
Normal 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"
|
||||
43
compose.yml
43
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=<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:
|
||||
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:-josh}/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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue