The five carried the hero only as an inline markdown image, but the index cards and the post hero figure render from hero/heroAlt frontmatter - so the cards showed no image and the post pages lost the hero treatment. Moved each hero into frontmatter and dropped the inline line (the body keeps only the diagram), matching every earlier post.
bztmon-site
The source for www.bztmon.com — Jonathon Wright's portfolio / résumé site. A fast, animated, security-hardened static site for a platform / infrastructure engineer.
This repo is public. It lives on a self-hosted public Gitea (
git.bztmon.com), isolated from the private homelab GitOps. Never commit secrets — the static site needs none.
Stack
- Astro (static output) + TypeScript + Tailwind v4
- Zero JS by default; tiny islands for the theme toggle + scroll reveals
- Content & config are data-driven (
src/data/) — adding a project never touches a component
Develop
npm install
npm run dev # http://localhost:4321
npm run check # astro check (types + diagnostics)
npm run build # static build → dist/
npm run preview # serve the build locally
npm run gen:og # regenerate the social-preview image (public/og.png)
Project layout
src/
data/ site.ts, socials.ts, skills.ts, projects.ts, experience.ts
components/ Hero, Nav, ThemeToggle, ProjectCard, SkillGroup, ...
layouts/ Layout.astro (SEO/OG, theme bootstrap)
pages/ index.astro, projects/, 404.astro
styles/ tokens.css (theme), global.css
lib/ build-time helpers (cv detection)
scripts/ gen-og.mjs, build-image.sh
nginx/ default.conf (security headers, caching) baked into the image
Dockerfile Debian build stage → nginx-unprivileged runtime
Content TODOs (Jonathon)
- Drop a real CV at
public/cv.pdf— the Download CV button appears automatically. - Fill the
TODO(Jonathon)markers insrc/data/experience.ts,projects.ts,socials.ts(employer names, dates, GitHub/LinkedIn handles).
Publishing a post
A post is just a Markdown file in src/content/blog/. Write one by hand, or generate
a schema-valid one with the publish helper (this is the seam an IaC/CI step calls):
# from flags
node scripts/new-post.mjs --title "My post" --summary "One line" \
--tags "kubernetes,gpu" [--draft] [--bodyFile notes.md]
# from a JSON event (e.g. an Ansible/AWX deploy summary)
echo '{"title":"...","summary":"...","tags":["x"],"body":"## Hi\n..."}' \
| node scripts/new-post.mjs --stdin
Commit the file to main → CI rebuilds and ships. A malformed post fails the build
(frontmatter is zod-validated), so a bad pipeline event never reaches production.
CI/CD
.gitea/workflows/deploy.yml runs on a self-hosted runner (a dedicated unprivileged
user on the bastion):
npm ci → astro check → audit-ci (high/critical gate) → build → scan dist →
SBOM (CycloneDX) → buildah build+push → open a digest-bump PR to home-ops
The PR is never auto-merged — home-ops main is branch-protected; merging it is
what triggers the ArgoCD rollout. The runner holds only least-privilege creds (a
home-ops-scoped deploy key + a PR token + a registry push token).
npm run scan— build-time gate: no secrets, no inline scripts, no third-party origins..audit-ci.json— fails on high/critical advisories. One allowlisted:GHSA-gv7w-rqvm-qjhr(esbuild install-integrity; build-time only, mitigated by the committed lockfile + trusted registry).renovate.json— keeps npm deps and the digest-pinned base images current.
Deploy
Built into a container image, served by nginx-unprivileged on a homelab Kubernetes
cluster, exposed via Cloudflare Tunnel. The image is pinned by digest in the private
home-ops repo and rolled out by ArgoCD. Manual/bootstrap build: scripts/build-image.sh push.
See SECURITY.md for the full security posture.