# Ed Stuurman - Full Site Documentation Archive
> Complete concatenated archive of stuurman.com for LLM ingestion, agent analysis, and retrieval.
> Canonical URL: https://www.stuurman.com
---
# About Ed Stuurman
- Role: Infrastructure & Site Reliability Engineer
- Location: Japan
- URL: https://www.stuurman.com/about/
## Summary
Ed Stuurman is an infrastructure and reliability engineer based in Japan. Over the past decade, his work has focused on mission-critical environments where downtime isn’t an option. His path started in Europe handling enterprise hardware escalations and kernel dump diagnostics at Hewlett Packard Enterprise, and continued in Tokyo scaling distributed logging and predictive email security platforms processing mail for millions of domains at Vade.
He ran Linux as his daily driver for 15 years starting in 2006 and has been daily driving a Mac for the past 5 years, and his engineering approach has always been hands-on: digging into complex, elusive problems—whether that is tracking down a reverse-proxy header flaw, diagnosing a CPU microcode bug, or provisioning multi-region cloud infrastructure with Terraform.
Outside of production systems, he enjoys working with his hands and staying active outdoors: shaping garden trees through niwaki (庭木), skiing, and building LEGO projects with his family.
## Career History
### Confidential — Confidential
- Period: November 2026 – Present
- Location: Japan
- Skills: Email, Infrastructure, Networking, Reliability
- Leading messaging infrastructure and enterprise mail delivery operations across APAC.
- Ensuring high-availability mail delivery, troubleshooting complex mail routing issues, and maintaining strict enterprise SLAs.
- Partnering cross-functionally to resolve high-priority delivery escalations and improve operational runbooks.
### Professional Sabbatical / Career Break — Self-Directed
- Period: October 2024 – October 2026 (2 yrs)
- Location: Japan
- Took a multi-year personal sabbatical dedicated to health recovery, family life, and intensive home landscaping and infrastructure projects.
- Balanced personal well-being with continuous self-directed technical focus, maintaining sharp expertise in high-volume systems architecture, email routing, and cloud infrastructure.
### Engineering Program Manager / Principal Messaging Engineer — Vade
- Period: June 2019 – September 2024 (5 yrs 4 mos)
- Location: Tokyo, Japan
- Progression: Customer Support Engineer → Principal Engineer → Engineering Program Manager
- Skills: AI, Email, Security, Observability, Docker, CI/CD, Infrastructure
- Spearheaded advanced engineering and program management for high-scale enterprise email security platforms protecting mail for millions of domains worldwide.
- Designed and scaled robust monitoring and logging architectures using the ELK stack (Elasticsearch, Logstash, Kibana) and Graylog, accelerating incident detection and minimizing MTTR.
- Conducted deep statistical accuracy and performance analyses across real-time blackhole lists (RBLs) to optimize filtering weights and platform accuracy.
- Built automated containerized services (Docker) and GitLab CI/CD pipelines with linting and unit testing to streamline continuous deployment.
- Partnered cross-functionally across global timezones to resolve tier-3 escalations and ensure carrier-grade service reliability.
### Server Response & Software Support Engineer — Hewlett Packard Enterprise (HPE)
- Period: September 2015 – November 2018 (3 yrs 3 mos)
- Location: Leixlip, Ireland
- Skills: VMware, Linux, Hardware, GPU, Windows Server, Networking
- Drove complex hardware and software escalations to root-cause resolution across HPE ProLiant BL, DL, ML, and XL server architectures.
- Investigated deep technical issues including minidump and kernel crash diagnostics, as well as GPU compute environments (NVIDIA Kepler and Maxwell architectures) for VDI and high-compute workloads.
- Provided high-tier software support across VMware vSphere, ESXi, and Microsoft Windows Server in complex multi-vendor enterprise environments (Cisco UCS, Dell PowerEdge).
- Coached and mentored technical peers in diagnostic methodologies, packet analysis with Wireshark, and fault isolation.
### Customer Advocate & Technical Support — OVHcloud (OVH)
- Period: December 2014 – July 2015 (8 mos)
- Location: Dublin, Ireland
- Skills: Cloud, OpenStack, Networking, Infrastructure
- Supported enterprise customers across public cloud infrastructure, bare-metal dedicated servers, and high-throughput networking.
- Represented OVH as brand ambassador at the OpenStack Summit in Vancouver.
- Delivered a technical presentation on Anycast IP load-balancing architecture at the OVH World Tour in the UK.
- Built trusted rapport with key clients, scaling several customer accounts from zero to over €100k+ in annual recurring cloud revenue.
### Technical Support Specialist — Google Nest (Voxpro)
- Period: August 2014 – December 2014 (5 mos)
- Location: Dublin, Ireland
- Skills: IoT, Networking, Hardware
- Provided specialized technical support and hardware diagnostics for Nest connected devices during the inaugural European market launch across Belgium, the Netherlands, and Luxembourg (Benelux).
- Diagnosed complex Wi-Fi networking, smart thermostat heating systems, and consumer IoT connectivity issues.
### Technical Support Analyst — Hewlett Packard (CPL Integrated Services)
- Period: February 2014 – August 2014 (7 mos)
- Location: Leixlip, Ireland
- Skills: Infrastructure, Windows Server, Systems
- Supported enterprise workstations, operating systems, and production systems for multinational corporate clients including AkzoNobel, Alcatel-Lucent, and Philips.
- Diagnosed and resolved complex application and corporate network incidents under strict enterprise SLAs.
## Peer Recommendations
### Peer Recommendation (MV)
“Thinking back on the 3 or so years that Ed and I worked together, the relationship that we built had a monumental impact on me personally and professionally. We were able to navigate complex situations with clear and direct communication and open feedback. Ed helped me see what is possible with motivation from a strong vision, clear strategy and goals. I was able to set the scene, sit back and watch Ed simply bloom.
Ed always stood out to me as one of the strongest examples I've ever seen as someone with a keen ability to learn, fixate on intricate, old, new and emerging technologies. Engrossing himself with them until he developed a deep understanding. Combined with a very practical and efficient work style, he is able to take on a high workload of complex activity.
Any time I invested in coaching or advising Ed was very well spent as he was quick to implement different approaches to his work. He has a tenacity, and a glow in him when he envisions something and is quick to execute to make it a reality.
Ed has a strong set of principals, his character is honorable and he accepts vulnerability to grow as a person. I was never left to wonder about his dedication and commitment to our goals, team and company. His authenticity gives him the capability to act as a coach and mentor which was pivotal to our teams development.
I would highly recommend Ed for roles that require tenacity, quick learning and high levels of technical and analytical proficiency.”
### Peer Recommendation (MS)
“When you think back to previous employment, there are always a few people that you miss working with. Ed is such a person and still remains one of my favorites colleagues to this day. Our work together was both as Hardware Engineers, but for different languages.
Even when working with a full team of technicians and engineers, Ed stood out as a "go-to" guy, as his technical ability and drive to learn always put in the front, even in a team of specialists. This is paired with a very open and cooperative personality, leading to frequent cases of knowledge exchange, improvement, talking shop, or simply friendly exchanges.
No matter what field Ed may be in, I can only imagine him as being at the center of it with 100% effort and anyone should be happy to have him join their team.”
### Peer Recommendation (AD)
“With much enthusiasm i recommend Ed to any IT job that are thinking on having him onboard.
Helping others and going the extra mile for the business needs are truly phenomenal.
When it comes to new hires and out of scope he is willing to help other people to improve themselves and learn them from his years of experience in the job field.
Have been working with Ed for sometime and every time a difficult IT related issue comes along Ed for me was first point of contact as i always got a clear view of the issue and a clear path forward.
He has a very special place for me as i learn so much from him and know he will go very far in life.”
### Peer Recommendation (YM)
“Ed is a serious guy with a professional attitude who is nice to work with. I'd work with him again with pleasure!”
### Peer Recommendation (PK)
“Ed has been a great Colleague at HPE and is very knoledgeable and reliable. He is also a great team player and always willing to help his peers. I would highly recomend Ed.”
### Peer Recommendation (MH)
“It was a pleasure to work with Ed on the very first days OVHcloud Public cloud customer support, more than 5 years ago. Everything had to be built from scratch and ED's curiosity, technicity and customer empathy were key to the success of what has quickly become the 3rd source of revenue for the company.
Fluent in multiple language, Ed was also very autonomous to write the technical content we were lacking. Looking forward to work with him again one day !”
### Peer Recommendation (MH)
“I had the pleasure of working with Ed for OVH, Ed is a talented, hard-working and friendly co-worker and a joy to work alongside.
Trustworthy and with great communication skills. If necessary he can translate technical terms to layman's language.
He is open to questions from as well co-workers, clients as managers of several levels and also gives great constructive feedback/advise. He is dedicated to all parts of his job.
In OVH he always went above and beyond in informing clients.”
### Peer Recommendation (DF)
“Ed was a great colleague to work with in HPE, always thinking ahead and very proactive, would recommend to any employer!”
---
# Articles Archive
---
# Why "aws s3 sync" Was Re-Uploading My Entire Site on Every Push (And How We Fixed Our CI/CD Pipeline)
- URL: https://www.stuurman.com/blog/2026-11-01-optimizing-gitlab-ci-s3-deployments/
- Published Date: Nov 01, 2026
- Tags: CI/CD, AWS, Infrastructure, Performance, AstroJS
- Description: How two sneaky CI/CD traps—npm ci wiping build caches inside node_modules and fresh build mtimes fooling aws s3 sync—turned one-line edits into full 592-file multi-region deployments.
When I moved this site off Amplify and onto a globally replicated S3 Multi-Region Access Point (MRAP) behind CloudFront, I thought the CI/CD pipeline was about as simple and done as a static site pipeline gets: build Astro in one stage, run `aws s3 sync . s3://my-bucket/ --delete` in the next, invalidate CloudFront, and call it a day.
And on paper, it worked. Every push to `main` built cleanly and showed up live a minute or two later.
Wait a minute, though: *why was a one-line typo fix taking a minute or two to build and deploy, and why was S3 Cross-Region Replication chewing through nearly 60 MB of traffic on every single commit?*
Once I actually looked under the hood of our GitLab CI pipeline today, I found two classic infrastructure traps hiding in plain sight.
---
## Trap #1: Caching `node_modules/` When `before_script` Runs `npm ci`
Like most GitLab CI templates for Node projects, my `.gitlab-ci.yml` originally cached `node_modules/` between builds and ran `npm ci` at the start of the build stage:
```yaml
cache:
paths:
- node_modules/
build:
stage: build
before_script:
- npm ci
script:
- npm run build
```
Spot the contradiction?
By design, **`npm ci` deletes the entire `node_modules/` directory** before installing a clean slate from `package-lock.json`. That means caching `node_modules/` in GitLab CI was doing nothing except wasting time zipping and unzipping an archive that `npm ci` immediately nuked on line one.
Worse yet, both Astro’s image optimization cache (`node_modules/.astro/assets/`, holding 284 responsive WebP variants of my [photography](/photography/) gallery) and our custom Satori + Resvg Open Graph card cache (`node_modules/.cache/og-images/`, holding 140 generated 1200×630 PNGs) lived *inside* `node_modules/`.
Locally, warm builds finished in **1.6 seconds** because both caches hit 100%. In CI, `npm ci` wiped both caches on every run, forcing the runner to re-encode 284 WebP photos and rasterize 140 Open Graph images from scratch every single time.
### The Fix: Move Build Caches Out of `node_modules/`
The fix was to separate the package manager cache from the build artifact cache and move both outside `node_modules/`:
1. In `astro.config.mjs`, we pointed Astro’s cache directory to `./.cache/astro`:
```js
export default defineConfig({
cacheDir: './.cache/astro',
// ...
});
```
2. In our Open Graph generator (`generateOgImage.ts`), we moved the SHA-256 content-addressed PNG cache to `./.cache/og-images/`.
3. In `.gitlab-ci.yml`, we cached `.cache/` and `.npm/` keyed on `package-lock.json`, and switched `npm ci` to pull from the local `.npm` tarball cache offline:
```yaml
cache:
key:
files:
- package-lock.json
paths:
- .cache/
- .npm/
build:
image: mcr.microsoft.com/playwright:v1.64.1-noble
stage: build
before_script:
- npm ci --cache .npm --prefer-offline
script:
- mkdir dist
- npx playwright install chromium
- npm run build
```
Now `npm ci` can blow away `node_modules/` all it wants—`.cache/astro` and `.cache/og-images` sit safely outside the blast radius, dropping warm build times from ~60 seconds down to ~1.6 seconds.
---
## Trap #2: Why `aws s3 sync` Re-Uploads Identical Files in CI
With the build stage sorted, I looked at the `deploytos3` stage:
```yaml
deploytos3:
image: registry.gitlab.com/gitlab-org/cloud-deploy/aws-base:latest
stage: deploytos3
script:
- cd ./dist
- aws s3 sync . s3://example-bucket-primary/ --delete
- aws cloudfront create-invalidation --distribution-id EXXXXXXXXXXXXX --paths '/*'
```
There’s a widespread assumption that `aws s3 sync` hashes your files and only uploads what changed. **It doesn’t.**
By default, `aws s3 sync` compares only two things: **file size** and **last-modified timestamp (`mtime`)**. If a local file has the exact same size as the object in S3 *but* its local `mtime` is newer than the S3 object’s timestamp, `aws s3 sync` assumes the file was modified and uploads it again.
In a static site CI pipeline, `npm run build` generates a brand-new `dist/` folder on every run. Every single HTML file, RSS feed, Open Graph PNG, and WebP photo gets a brand-new `mtime` of "right now." So `aws s3 sync` dutifully re-uploaded all **592 files (58 MB)** on every push.
And because my primary Tokyo bucket (`ap-northeast-1`) replicates via S3 Cross-Region Replication to secondary buckets across Europe, the US, Asia-Pacific, and South America behind our [S3 Multi-Region Access Point](/blog/2025-12-6-aws-mrap-s3-lambda-cloudfront/), every single redundant `PUT` was multiplied across the globe—followed by a blanket `/*` CloudFront invalidation that dumped the edge cache for the whole site.
```mermaid
flowchart LR
Build["Astro Build (dist/) 592 files with fresh mtime"] --> Sync["s3-deploy.sh MD5 vs S3 ETag diff"]
Sync -->|Unchanged MD5| Skip["Skip Upload (0 PUTs)"]
Sync -->|Changed / New| Upload["Upload Only Delta + Targeted CF Invalidation"]
```
---
## Writing an MD5/ETag-Aware Deploy Script (And a Container Gotcha)
To make deploys genuinely incremental without relying on local filesystem timestamps, we wrote a custom `s3-deploy.sh` script.
Instead of trusting `mtime`, the script queries S3 once via `aws s3api list-objects-v2` to grab the `Key` and `ETag` (which for non-multipart objects is simply the hex MD5 digest) of every object currently in the bucket, hashes the local files in `dist/`, and only uploads files that are new or whose MD5 actually changed.
### The First Attempt: `python3: command not found`
My first draft used a short inline `python3` snippet to parse the JSON response from `list-objects-v2` and compute the MD5 diff. It ran great on macOS—and immediately faceplanted in GitLab CI:
```text
./scripts/s3-deploy.sh: line 27: python3: command not found
```
Even though AWS CLI v2 is written in Python, the `registry.gitlab.com/gitlab-org/cloud-deploy/aws-base:latest` container ships a self-contained embedded runtime for `aws` and strips out a standalone `python3` binary (as well as `jq`). What *is* inside the container? Standard POSIX/GNU coreutils: `bash`, `awk`, `find`, `md5sum`, `sort`, and `tr`.
And honestly, when you have `aws --query` and `awk`, you don't need Python at all.
### The Final Zero-Dependency POSIX Script
By asking the AWS CLI to format its output as tab-separated text (`--query 'Contents[].[Key, ETag, Size]' --output text`), we can feed the remote inventory and a single batch `find ... -exec md5sum {} +` pass directly into `awk`:
```bash
#!/usr/bin/env bash
set -euo pipefail
BUCKET="${S3_BUCKET:-example-bucket-primary}"
DIST_ID="${CLOUDFRONT_DISTRIBUTION_ID:-EXXXXXXXXXXXXX}"
DIST_DIR="${1:-./dist}"
if [ ! -d "$DIST_DIR" ]; then
echo "Error: build output directory '$DIST_DIR' does not exist." >&2
exit 1
fi
cd "$DIST_DIR"
echo "==> Fetching remote object inventory from s3://${BUCKET} ..."
REMOTE_TSV="$(mktemp)"
# Output tab-separated lines: Key \t ETag \t Size
aws s3api list-objects-v2 \
--bucket "$BUCKET" \
--query 'Contents[].[Key, ETag, Size]' \
--output text | tr -d '"' > "$REMOTE_TSV"
echo "==> Remote inventory complete."
LOCAL_TSV="$(mktemp)"
# Compute MD5 for all local files in a single batch (Linux md5sum + macOS md5 -r fallback)
if command -v md5sum >/dev/null 2>&1; then
find . -type f -exec md5sum {} + | sed 's| \./|\t|' > "$LOCAL_TSV"
else
find . -type f -exec md5 -r {} + | sed 's| \./|\t|' > "$LOCAL_TSV"
fi
PLAN_FILE="$(mktemp)"
awk -F'\t' '
NR == FNR {
if (NF >= 2 && $1 != "None") {
remote_etag[$1] = $2
remote_size[$1] = $3
}
next
}
{
md5 = $1
key = $2
local_seen[key] = 1
if (!(key in remote_etag)) {
print "UPLOAD\t" key
} else if (remote_etag[key] != md5) {
# Skip multipart ETag false-positives on immutable _astro/ assets
if (index(remote_etag[key], "-") > 0 && index(key, "_astro/") == 1) {
next
}
print "UPLOAD\t" key
}
}
END {
for (k in remote_etag) {
if (!(k in local_seen)) {
print "DELETE\t" k
}
}
}
' "$REMOTE_TSV" "$LOCAL_TSV" | sort -k1,1r -k2,2 > "$PLAN_FILE"
rm -f "$REMOTE_TSV" "$LOCAL_TSV"
```
Once `awk` emits the `UPLOAD` and `DELETE` plan, the second half of the script applies the changes and collects only the mutable URL paths that actually changed for CloudFront:
```bash
UPLOAD_COUNT=0
DELETE_COUNT=0
CHANGED_PATHS=()
while IFS=$'\t' read -r action key; do
[ -z "${action:-}" ] && continue
if [ "$action" = "UPLOAD" ]; then
UPLOAD_COUNT=$((UPLOAD_COUNT + 1))
if [[ "$key" == _astro/* ]]; then
aws s3 cp "$key" "s3://${BUCKET}/${key}" \
--cache-control "public, max-age=31536000, immutable" \
--only-show-errors
else
aws s3 cp "$key" "s3://${BUCKET}/${key}" \
--only-show-errors
if [[ "$key" == */index.html ]]; then
CHANGED_PATHS+=("/${key%index.html}")
CHANGED_PATHS+=("/${key}")
elif [[ "$key" == "index.html" ]]; then
CHANGED_PATHS+=("/")
CHANGED_PATHS+=("/index.html")
else
CHANGED_PATHS+=("/${key}")
fi
fi
echo " [uploaded] $key"
elif [ "$action" = "DELETE" ]; then
DELETE_COUNT=$((DELETE_COUNT + 1))
aws s3 rm "s3://${BUCKET}/${key}" --only-show-errors
CHANGED_PATHS+=("/${key}")
echo " [deleted] $key"
fi
done < "$PLAN_FILE"
rm -f "$PLAN_FILE"
echo "==> Sync summary: ${UPLOAD_COUNT} uploaded, ${DELETE_COUNT} deleted."
if [ "${#CHANGED_PATHS[@]}" -eq 0 ]; then
echo "==> No mutable routes changed; skipping CloudFront invalidation."
elif [ "${#CHANGED_PATHS[@]}" -gt 25 ]; then
echo "==> ${#CHANGED_PATHS[@]} paths changed (>25); invalidating /* on CloudFront (${DIST_ID})..."
aws cloudfront create-invalidation --distribution-id "$DIST_ID" --paths "/*"
else
echo "==> Invalidating ${#CHANGED_PATHS[@]} changed path(s) on CloudFront (${DIST_ID})..."
aws cloudfront create-invalidation --distribution-id "$DIST_ID" --paths "${CHANGED_PATHS[@]}"
fi
```
---
## What This Buys Us
1. **Zero Redundant S3 Uploads & Replication:** When I fix a typo in a single blog post, the deploy stage now uploads only the 4–5 files whose MD5 actually changed (the post HTML, the blog index, the RSS/Atom/LLM feeds) instead of pushing all 592 files and 58 MB across every replicated S3 region.
2. **Long-Lived Immutable Caching on `_astro/*`:** Content-hashed bundles and WebP photos in `_astro/*` now get `Cache-Control: public, max-age=31536000, immutable` headers on upload, and never trigger a CloudFront invalidation since their filenames are already content-addressed.
3. **Surgical CloudFront Invalidations:** If fewer than 25 mutable routes changed, we invalidate only those exact paths so every other page stays warm at CloudFront edge locations worldwide (and if a site-wide layout tweak touches more than 25 paths, it falls back to `/*` so we stay well inside CloudFront’s free monthly invalidation tier).
Sometimes the best infrastructure improvements aren't adding new services—they're noticing where your defaults are quietly doing 100× more work than necessary.
---
# Stepping Back, Digging In, and Stepping Forward
- URL: https://www.stuurman.com/blog/2026-10-31-stepping-back-digging-in-and-stepping-forward/
- Published Date: Oct 31, 2026
- Tags: Career, Engineering, Infrastructure, Reflection
- Description: Reflections on a two-year hiatus, rebuilding mental bandwidth, and stepping back into production infrastructure.
Two years ago, I logged off.
In an industry obsessed with relentless upward velocity, constant commits, and measuring personal worth by the freshness of your profile activity, deliberately stepping away feels almost unnatural. If you don’t broadcast what you’re shipping every quarter, the prevailing tech myth suggests your engineering instincts will rust away overnight.
It turns out, the opposite is true.
### The Real Cost of Constant Uptime
When you spend years managing high-stakes systems, coordinating incident response, and wrestling complex operational infrastructure, your focus inevitably narrows. You live inside a reactive loop: the next sprint, the next edge-case, the next high-priority customer escalation. You solve hard problems, but you solve them against the clock.
Stepping back for two years gave me something exceedingly rare in tech: uninterrupted mental bandwidth.
I didn’t stop building; I simply stopped building under artificial deadlines. When you aren’t running on ambient adrenaline or fielding pages at 2:00 AM, genuine curiosity takes over again. You tear down your home infrastructure, experiment with cloud primitives, tune hypervisors, and solve physical engineering problems with your hands. You read specifications and protocol docs out of pure interest rather than urgent triage.
Stepping away doesn't dull your edge. If anything, it strips away the fleeting hype cycles and reinforces what actually matters: rock-solid network fundamentals, clean architecture, and the discipline of methodical, root-cause troubleshooting.
### The Reset
The hiatus wasn't just technical—it was personal. It was time grounded in family, watching the seasons changing, literally digging into the earth, and stepping completely outside the corporate perimeter.
Engineers spend careers designing systems to eliminate single points of failure, yet we rarely apply that same redundancy and recovery planning to our own lives. Taking a dedicated reset rebuilds real resilience. You return with clearer priorities, sharper focus, and zero lingering burnout.
### Navigating the ATS Filter
Stepping back into the job market also provided a practical look at modern hiring pipelines.
Modern recruitment relies heavily on Applicant Tracking Systems (ATS). From an engineering standpoint, the parser is straightforward: it matches keywords and flags chronological gaps. If your timeline doesn't look like an unbroken conveyor belt, the filter quietly drops the packet before any human sees it.
It is an understandable triage mechanism for volume, but it is fundamentally blind to context, architectural intuition, or how someone actually troubleshoots under pressure. Rather than spending time gaming keyword algorithms, the breakthrough came through people: connecting directly with engineering leaders and the unsung heroes in the recruiting industry who look beyond automated filters, recognize demonstrable proof of work, and value operational substance over rigid checklists.
### The Next Chapter
Now, the reset is complete, and it’s time to step back into production.
I’m stepping back into the operational trenches to work on core messaging and mail delivery infrastructure. The plumbing that moves enterprise email remains one of the most demanding, unforgiving, and fundamentally critical backbones of the modern internet. It’s an ecosystem where protocol precision still matters, where deliverability doesn't tolerate sloppy abstractions, and where deep diagnostics still win the day.
Returning to that world—where millions of critical handshakes happen under the hood every hour—feels like coming home to the kind of high-stakes problem-solving I enjoy most.
To anyone staring down a gap on their resume or wondering whether it’s possible to step off the treadmill and come back sharper: your craft doesn't vanish just because you closed the laptop for a season or two. Sometimes, the best maintenance an engineer can do is stepping away long enough to remember why they loved solving hard problems in the first place.
Here’s to day one.
---
# From Static Tags to a Living CV: Pair-Programming a Site Architecture with AI
- URL: https://www.stuurman.com/blog/2026-10-29-from-tags-to-living-cv-ai-assisted-architecture/
- Published Date: Oct 29, 2026
- Tags: AI, AstroJS, Career, Architecture, Opinion
- Description: How linking career milestones, homelab notes, and photography through simple tags sets up cleaner CV generation—and what pair-programming with AI as an amateur actually feels like.
Most personal engineering websites treat tags as an afterthought. You write an article, slap five lowercase keywords at the bottom, and end up with a predictable cloud of `#kubernetes` or `#javascript` links that lead to thin archive pages. Case in point: I do this all the time.
When I started revamping this site, I wanted the tags to actually do something useful. Over a career spanning enterprise systems support at HPE, principal messaging engineering and program management at Vade, and ongoing high-availability mail delivery operations across APAC (based in Japan), my work has never existed in isolated silos. Technical architecture, daily problem-solving logs, career milestones, a little time in the garden, and life behind the camera all inform one another.
Instead of just spitting out article links, I wanted clicking a tag to connect the dots: career milestones, relevant technical deep dives, homelab field notes, and even photos where relevant. And as a practical side benefit, structuring everything cleanly lays the groundwork for generating tailored, accurate CVs without an LLM having to hallucinate my past.
---
## Connecting Milestones to Real Work
The core problem with traditional resumes is that they are flat assertions. A PDF bullet point says:
> *"Spearheaded advanced engineering for high-scale enterprise email security platforms protecting mail for millions of domains worldwide."*
Recruiters and hiring managers read that and have to take your word for it. They can't easily click through to see the actual problems you solved, the postmortems you logged, or how you think.
In this setup, tags act as connective tissue across four distinct data collections:
1. **Career Experience (`experiences.ts`):** Structured role histories containing verified production highlights, enterprise environments, and core competencies.
2. **Technical Articles (`src/content/blog/`):** Deep dives into multi-region cloud storage, email deliverability, DNS, virtualization, or whatever else I fancy.
3. **Operational Logs (`src/content/log/`):** Timestamped field notes from the engineering trenches—capturing day-to-day problem solving, homelab hardware quirks, Proxmox hypervisor passthrough, and IoT garden automation.
4. **Picture Logs (`src/content/photolog/`):** Atmospheric photographic stories and observations captured across the world—documenting Aegean cliffside architecture, spring cherry blossoms along quiet Tokyo canals, Kenzo Tange’s concrete curves, and contemporary art exhibitions.
```mermaid
flowchart TD
Tag["🏷️ Tag: #Infrastructure"]
Tag --> Career["Career Milestone Vade EPM"]
Tag --> DeepDive["Technical Deep Dive MRAP, S3 & CloudFront"]
Tag --> FieldLog["Operational Field Log Deliverability Ops"]
```
When you click a tag like [`#Email`](/tags/email/) or [`#Infrastructure`](/tags/infrastructure/) or [`#AI`](/tags/ai/), the route doesn't just show a list of blog titles. The page dynamically pulls the matching career roles, surfaces the exact bullet-point highlights where that competency was exercised under enterprise SLAs, and displays the technical articles and real-life logs that substantiate it.
It turns a lazy list of links into something tangible.
---
## Keeping Things Sane: Engineering vs. Photography Tags
One immediate headache was tag clutter. With nearly 40 photo entries on the site, I didn't want the engineering tags buried under purely visual keywords like `#Sakura`, `#Nightscape`, or `#Canal Walk`.
The fix was straightforward:
- **Photography Topics:** Pure visual themes stay in their own dedicated, neutral-toned gallery section.
- **Engineering & Career Topics:** Any tag that appears in career experiences, blog write-ups, or operational logs automatically routes to the primary engineering taxonomy.
- **Cross-pollination:** If a photography tag happens to overlap with engineering (like `#Architecture`, `#Geometry`, or `#AI`), it automatically promotes into the main tag index.
This keeps the site’s professional focus front and center without hiding the creative side.
---
## Making CV Generation Less Painful
Structuring career highlights, technical essays, and observational notes into typed Astro collections does more than just organize the site: it creates a clean data source for **future CV generation**.
Tailoring a CV for different opportunities is notoriously tedious. One role might call for a Principal Systems Engineer with deep Linux kernel and bare-metal server experience. Another might seek an Engineering Program Manager who excels at cross-timezone team leadership and complex carrier migrations.
Because every highlight in `experiences.ts` is tagged and backed by real entries:
- **No Hallucinations:** When feeding an LLM a prompt to build a tailored resume, it pulls from genuine, pre-written accomplishment bullets in `experiences.ts` tied to specific tags (like `#Infrastructure` or `#Security`). It doesn't have to guess or embellish.
- **Real Context:** Instead of sterile bullet points, an exported summary can weave in real context—such as field notes on Tokyo architecture or reflections from an art exhibition at Roppongi’s Mori Tower. It conveys not just *what* was built, but *how* I think and solve problems.
- **Single Source of Truth:** Updating a job accomplishment or logging a new milestone in git automatically refreshes the portfolio, the tag cross-references, and any downstream generated resumes simultaneously.
---
## Pair-Programming with AI (When You’re Not a Frontend Dev)
This site wasn't generated by a single prompt asking an LLM to "build me a portfolio with a cool tagging system." I'm admittedly an amateur when it comes to web development, and in my experience, monolithic prompts almost always yield generic, bloated code that breaks when integrated with real compilers and stylesheets. Trying to untangle 500 lines of generated code when you aren't a frontend expert is miserable.
Instead, I used an AI coding assistant (Antigravity) as a hands-on pair programmer, steering it with short, incremental prompts under close supervision:
1. **Reverting and Resetting:** When an early experiment with an embedded slide-out card inside the lightbox modal proved overly intrusive, we immediately scrapped the UI change while keeping the underlying data model intact.
2. **Establishing Dedicated Spaces:** We created clean photo cards on `/log/`, ensuring every entry had a title, formatted date, location, and tags linking back to the LightGallery modal.
3. **Polishing User Experience:** We cut down visual clutter by removing redundant buttons, turning entire cards into clickable targets, and styling tag badges in subtle neutral grays for photography and warm amber for career highlights.
4. **Migrating to Astro Content Collections:** We moved runtime TypeScript data objects into native Markdown files in `src/content/photolog/`, allowing full Astro schema validation and individual story editing.
5. **Human Ground-Truthing:** When the AI enriched stories by scanning visual contact sheets, it made an initial aesthetic guess about photo #7 (calling it a structural timber lattice). Because I was there in person, I corrected it: *“Golden Lattice is actually a burnt piano and chair with black twine attached everywhere, an art exhibition in Mori Tower Roppongi.”* The assistant immediately rewrote the story to capture Chiharu Shiota’s installation with complete accuracy.
6. **Micro-interaction Polish:** Finally, we tightened the lightbox captions so that the photo title, date, and link to the picture log sit cleanly on a single row, overriding LightGallery's internal CSS specificity to ensure the link retains our site-wide amber accent (`#fbbf24`).
Each step took two to three minutes—always under supervision. Because the assistant had terminal and file-system access within the workspace, it could execute AST modifications, verify with headless Chrome screenshots, and run `npm run build` after every single turn.
---
## What a Personal Site Can Be
In 2026, a software engineer’s website shouldn't just be an electronic business card or a neglected blog (knock on wood).
Connecting career milestones, operational logs, and life behind the camera doesn't require over-engineered enterprise tooling. With a clean static framework like Astro and a bit of patient, supervised AI pair-programming, you can build something genuinely personal—and actually useful.
---
# Overhauling the Stack: Astro 7, Tailwind v4, and Converting to AI-Assisted Engineering
- URL: https://www.stuurman.com/blog/2026-10-27-converting-to-ai-assisted-engineering/
- Published Date: Oct 27, 2026
- Updated Date: Oct 29, 2026
- Tags: AstroJS, Tailwind CSS, AI, Infrastructure, Opinion
- Description: How a routine framework upgrade turned into a full modernization sprint—and why I finally became a believer in AI pair programming.
Back in late 2025, when I first spun up this website, I wrote a post about falling down the static-site rabbit hole. The goal back then was simple: get a clean, automated pipeline going with Astro, push markdown to git, and let the cloud handle the rest. It did the job, but like any side project left running for a while, dependencies fell behind, layout patterns aged poorly, and the technical debt started piling up.
Over the past few hours, I sat down to do what was supposed to be a quick maintenance pass. That quick pass turned into a major overhaul: upgrading to **Astro 7**, migrating to **Tailwind CSS v4**, implementing a dark mode toggle, auditing SEO, and completely restructuring the layout.
More importantly, it’s the project that officially converted me to AI-assisted engineering.
## What Changed Under the Hood
A lot has shifted in the frontend ecosystem over the last year. The update wasn't just bumping version numbers in `package.json`; it was a fundamental architectural cleanup:
- **Upgrading to Astro 7:** Astro 7 brings Rolldown/rs-compiler builds that compile all static pages of this site in under 750 milliseconds. However, Astro 7 is much stricter about component AST boundaries. The legacy pattern of opening `` and `
` tags in `Head.astro` and closing them in `Footer.astro` broke completely under the new compiler. We refactored everything into a unified, clean `BaseLayout.astro` that wraps pages self-contained.
- **Tailwind CSS v4 Migration:** Tailwind v4 moves away from the old `tailwind.config.js` model into a purely CSS-native architecture via `@import "tailwindcss";`. We configured `@custom-variant dark (&:where(.dark, .dark *));` in our global styles to support class-based dark mode cleanly.
- **Real Dark Mode & Theme Toggle:** Added a theme toggle to the navigation (and mobile menu) that flips between Sun and Moon icons via pure CSS, remembers user preference in `localStorage`, and runs a small inline script in `` to eliminate any flash of unstyled theme (FOUT).
- **SVG & Design Polish:** Converted the static logo SVG into a native Astro component (``) with `fill="currentColor"`, allowing it to respond directly to Tailwind `fill-*` and `text-*` classes. The navigation now dynamically tracks the active page category with an amber underline while inactive links highlight gray on hover.
- **SEO & Structured Data:** Wired up dynamic Open Graph meta tags, Twitter card summaries, and schema.org JSON-LD schemas (`BlogPosting`, `Person`, `WebSite`) across every page.
- **Cutting the Corporate Fluff:** Stripped out the generic, buzzword-heavy AI copy on the Home and About pages, rewriting it in my own voice to reflect my actual background in systems infrastructure and life here in Japan.
## The Shift: From Skeptic to Convert
I’ve held a dose of skepticism toward AI coding tools.
Most demos look impressive until you put them in front of a real, messy codebase with broken builds, mismatched peer dependencies, and custom build pipelines. Too often, generative tools spit out generic code snippets, hallucinate non-existent API methods, or write copy that reads like a zealot wrote it.
This project was the first time I experienced true **agentic pair programming**, and it completely changed my perspective.
Instead of copying and pasting prompts into a web chat window, I had an agent working directly inside my workspace terminal and file tree:
1. **Navigating breaking compiler errors:** When Astro 7 rejected unbalanced JSX tags in legacy layouts, the assistant diagnosed the exact AST failure, created a clean unified layout, and migrated every single page template across the site.
2. **Handling build environments:** When macOS sandbox keychain checks crashed Node processes during package updates, it identified the root cause and systematically verified every change with local production builds.
3. **Connecting external context:** By hooking into the Google Drive CLI directly from the terminal, the assistant read through my actual resumes, cover letters, and interview notes to understand my real engineering background, allowing us to collaboratively rewrite the site’s copy in a voice that actually sounds like me.
## The Takeaway
I used to think of AI in software development as either an autocomplete gimmick or an autopilot that writes mediocre boilerplate. I now see it as an extraordinary force multiplier.
It doesn’t replace foundational engineering knowledge—you still need to know how the DOM works, how compilers parse ASTs, how networking and DNS propagate, and how to spot bad code. But when you combine hands-on domain experience with an agent that can execute multi-file refactors, verify builds, and handle tedious pipeline glue in seconds, the development velocity is unlike anything I’ve seen before.
I’m officially converted.
---
# Using Terraform (HCL) to design, deploy, and manage repeatable multi-region AWS infrastructure.
- URL: https://www.stuurman.com/blog/2025-12-18-terraform-aws-mrap-s3/
- Published Date: Dec 18, 2025
- Tags: AWS, Infrastructure, Terraform
- Description: Automating a setup that takes an hour to complete manually, because repetition is a problem best solved once.
> [!WARNING]
> This article is functional but out of date.
> AWS MRAP behind cloudfront no longer requires a lambda function for signing requests.
I’ve been experimenting with Terraform for some time now. Once you understand what HCL can and cannot do, it becomes a genuinely enjoyable and worthwhile exercise.
For this project, I chose to store the Terraform state remotely in an S3 bucket, using S3 based locking as well. This aligns with Terraform best practices and ensures safe state management.
Before getting started, there are a few prerequisites:
- Update the list of regions in `variables.tf` to add or remove deployment targets.
- The first entry is treated as the primary (master) region, from which files are replicated.
- Set the domain name and domain slug.
- The domain must already exist as a hosted zone in Route 53.
- Provide your AWS `account_id`.
- Create and configure your own `backend.tf`.
- In my case, I use an S3 bucket with state locking enabled, as recommended for Terraform state management.
```hcl
variable "buckets" {
type = list(object({
region = string
is_main = bool
priority = optional(number)
}))
default = [
{
region = "ap-northeast-1"
is_main = true
priority = null
},
{
region = "eu-north-1"
is_main = false
priority = 0
},
{
region = "eu-west-1"
is_main = false
priority = 1
},
{
region = "us-west-2"
is_main = false
priority = 2
},
{
region = "us-east-1"
is_main = false
priority = 3
},
{
region = "ap-south-1"
is_main = false
priority = 4
}
]
}
variable "environment_domain" {
type = string
default = "example.com"
}
variable "environment_domain_slug" {
type = string
default = "example-com"
}
variable "account_id" {
type = string
default = "111111111111"
}
```
As you may have noticed, the regions defined in variables.tf differ from those used in this [website’s current hosting setup](/blog/2025-12-6-aws-mrap-s3-lambda-cloudfront/). The intent was to experiment with AWS directory buckets in pursuit of even lower latency. Unfortunately, from what I’ve been able to determine, directory buckets are not compatible with multi region access points (MRAP) and are not available in every AWS region. As a result, classic S3 buckets will have to do.
At this point, I’ll let the HCL do the talking:
```hcl
locals {
all_buckets = { for bucket in var.buckets : bucket.region => bucket }
}
resource "aws_s3_bucket" "s3_buckets" {
for_each = local.all_buckets
bucket = "${var.environment_domain_slug}-${each.value.region}"
region = each.value.region
}
# Enable versioning for the buckets
resource "aws_s3_bucket_versioning" "s3_versioning" {
for_each = local.all_buckets
depends_on = [aws_s3_bucket.s3_buckets]
bucket = "${var.environment_domain_slug}-${each.value.region}"
region = each.value.region
versioning_configuration {
status = "Enabled" # Valid values: "Enabled", "Suspended"
}
}
# IAM Role for Replication
resource "aws_iam_role" "replication_role" {
name = "${var.environment_domain_slug}-s3-replication-role"
assume_role_policy = jsonencode({
Version = "2012-10-17"
Statement = [{
Effect = "Allow"
Principal = { Service = "s3.amazonaws.com" }
Action = "sts:AssumeRole"
}]
})
}
# IAM Policy for Replication
resource "aws_iam_policy" "replication_policy" {
for_each = { for s3_buckets in var.buckets : s3_buckets.region => s3_buckets if !s3_buckets.is_main }
name = "${var.environment_domain_slug}-${each.value.region}-s3-replication-policy"
description = "Allows S3 replication between main and secondary buckets"
depends_on = [aws_iam_role.replication_role]
policy = jsonencode({
Version = "2012-10-17"
Statement = [
{
Effect = "Allow"
Action = ["s3:*"]
Resource = "*"
}
]
})
}
# Attach Policy to Role
resource "aws_iam_role_policy_attachment" "replication_policy_attach" {
for_each = { for s3_buckets in var.buckets : s3_buckets.region => s3_buckets if !s3_buckets.is_main }
depends_on = [aws_s3_bucket_versioning.s3_versioning]
role = "${var.environment_domain_slug}-s3-replication-role"
policy_arn = "arn:aws:iam::${var.account_id}:policy/${var.environment_domain_slug}-${each.value.region}-s3-replication-policy"
}
# S3 Replication Configuration
resource "aws_s3_bucket_replication_configuration" "replication" {
bucket = "${var.environment_domain_slug}-${lookup(var.buckets[0], "region")}"
role = "arn:aws:iam::${var.account_id}:role/${var.environment_domain_slug}-s3-replication-role"
region = lookup(var.buckets[0], "region")
depends_on = [aws_s3_bucket_versioning.s3_versioning]
dynamic "rule" {
for_each = { for s3_buckets in var.buckets : s3_buckets.region => s3_buckets if !s3_buckets.is_main }
content {
id = "${var.environment_domain_slug}-${rule.value.region}"
status = "Enabled"
priority = rule.value.priority
filter {
prefix = "" # Replicate all objects
}
destination {
bucket = "arn:aws:s3:::${var.environment_domain_slug}-${rule.value.region}"
storage_class = "STANDARD"
replication_time {
status = "Enabled"
time {
minutes = 15
}
}
metrics {
event_threshold {
minutes = 15
}
status = "Enabled"
}
}
delete_marker_replication {
status = "Enabled"
}
}
}
}
# S3 SSE-S3 encryption
resource "aws_s3_bucket_server_side_encryption_configuration" "encrypt_all_buckets" {
for_each = local.all_buckets
bucket = "${var.environment_domain_slug}-${each.value.region}"
region = each.value.region
rule {
apply_server_side_encryption_by_default {
sse_algorithm = "AES256" # This specifies SSE-S3
}
bucket_key_enabled = true
}
}
# Create the Multi-Region Access Point (MRAP)
resource "aws_s3control_multi_region_access_point" "mrap" {
# MRAPs are global resources and do not require a specific region provider
details {
name = "${var.environment_domain_slug}-mrap"
# Configure the regions associated with the MRAP
dynamic "region" {
for_each = local.all_buckets
content {
bucket = "${var.environment_domain_slug}-${region.value.region}"
}
}
}
}
resource "aws_s3control_multi_region_access_point_policy" "mrap" {
details {
name = "${var.environment_domain_slug}-mrap"
policy = jsonencode({
"Version" : "2012-10-17",
"Statement" : [
{
"Sid" : "AllowMultiRegionAccessPointRead",
"Effect" : "Allow",
"Principal" : {
"AWS" : "${var.account_id}",
"Service" : ["cloudfront.amazonaws.com"]
},
"Action" : ["s3:GetObject", "s3:ListBucket"],
"Resource" : [
"arn:aws:s3::${var.account_id}:accesspoint/${aws_s3control_multi_region_access_point.mrap.alias}/object/*",
"arn:aws:s3::${var.account_id}:accesspoint/${aws_s3control_multi_region_access_point.mrap.alias}"
]
}
]
})
}
}
# SSL Certificate and validation
resource "aws_acm_certificate" "cert" {
region = "us-east-1"
domain_name = "*.${var.environment_domain}"
validation_method = "DNS"
lifecycle {
create_before_destroy = true
}
}
data "aws_route53_zone" "dns_zone" {
name = var.environment_domain
private_zone = false
}
resource "aws_route53_record" "dns_records" {
for_each = {
for dvo in aws_acm_certificate.cert.domain_validation_options : dvo.domain_name => {
name = dvo.resource_record_name
record = dvo.resource_record_value
type = dvo.resource_record_type
}
}
allow_overwrite = true
name = each.value.name
records = [each.value.record]
ttl = 60
type = each.value.type
zone_id = data.aws_route53_zone.dns_zone.zone_id
}
resource "aws_acm_certificate_validation" "validated_cert" {
certificate_arn = aws_acm_certificate.cert.arn
validation_record_fqdns = [for record in aws_route53_record.dns_records : record.fqdn]
}
# CloudFront
resource "aws_cloudfront_distribution" "mrap_distribution" {
origin {
custom_origin_config {
http_port = "80"
https_port = "443"
origin_protocol_policy = "https-only"
origin_read_timeout = "30"
origin_ssl_protocols = ["TLSv1.2", "TLSv1.1", "TLSv1"]
}
domain_name = aws_s3control_multi_region_access_point.mrap.domain_name
origin_id = "mrap_origin_id"
}
enabled = true
is_ipv6_enabled = true
default_root_object = "index.html"
aliases = ["www.${var.environment_domain}"]
default_cache_behavior {
allowed_methods = ["DELETE", "GET", "HEAD", "OPTIONS", "PATCH", "POST", "PUT"]
cached_methods = ["GET", "HEAD"]
target_origin_id = "mrap_origin_id"
lambda_function_association {
event_type = "origin-request"
lambda_arn = "${aws_lambda_function.edge_lambda.arn}:${aws_lambda_function.edge_lambda.version}"
}
forwarded_values {
query_string = false
cookies {
forward = "none"
}
}
viewer_protocol_policy = "allow-all"
min_ttl = 0
default_ttl = 3600
max_ttl = 86400
}
# Cache behavior with precedence 0
ordered_cache_behavior {
path_pattern = "/*"
allowed_methods = ["GET", "HEAD", "OPTIONS"]
cached_methods = ["GET", "HEAD", "OPTIONS"]
target_origin_id = "mrap_origin_id"
lambda_function_association {
event_type = "origin-request"
lambda_arn = "${aws_lambda_function.edge_lambda.arn}:${aws_lambda_function.edge_lambda.version}"
}
forwarded_values {
query_string = false
headers = ["Origin"]
cookies {
forward = "none"
}
}
min_ttl = 0
default_ttl = 86400
max_ttl = 31536000
compress = true
viewer_protocol_policy = "redirect-to-https"
}
price_class = "PriceClass_200"
restrictions {
geo_restriction {
restriction_type = "none"
locations = []
}
}
tags = {
Environment = "production"
}
viewer_certificate {
acm_certificate_arn = aws_acm_certificate.cert.arn
ssl_support_method = "sni-only"
}
}
# Create Route53 records for the CloudFront distribution aliases
data "aws_route53_zone" "site_dns_zone" {
name = var.environment_domain
}
resource "aws_route53_record" "cloudfront" {
for_each = aws_cloudfront_distribution.mrap_distribution.aliases
zone_id = data.aws_route53_zone.site_dns_zone.zone_id
name = each.value
type = "A"
alias {
name = aws_cloudfront_distribution.mrap_distribution.domain_name
zone_id = aws_cloudfront_distribution.mrap_distribution.hosted_zone_id
evaluate_target_health = false
}
}
# Lambda @Edge settings
resource "aws_lambda_function" "edge_lambda" {
provider = aws # deploy to us-east-1
region = "us-east-1"
function_name = "${var.environment_domain_slug}-lambda"
filename = "./mrap-to-cdn-proxy-us-east-1.zip"
#source_code_hash = data.archive_file.lambda_zip.output_base64sha256
handler = "index.handler"
runtime = "nodejs24.x"
role = aws_iam_role.lambda_exec.arn
publish = true # Must publish
}
resource "aws_iam_role" "lambda_exec" {
name = "${var.environment_domain_slug}-lambda"
assume_role_policy = jsonencode({
Version = "2012-10-17"
Statement = [
{
Action = "sts:AssumeRole"
Effect = "Allow"
Principal = {
Service = "lambda.amazonaws.com"
}
},
{
Action = "sts:AssumeRole"
Effect = "Allow"
Principal = {
Service = "edgelambda.amazonaws.com"
}
}
]
})
}
resource "aws_lambda_permission" "allow_cloudfront" {
provider = aws
statement_id = "AllowExecutionFromCloudFront"
action = "lambda:InvokeFunction"
function_name = aws_lambda_function.edge_lambda.arn
principal = "edgelambda.amazonaws.com"
source_arn = "arn:aws:cloudfront::${var.account_id}:distribution/${aws_cloudfront_distribution.mrap_distribution.id}"
}
# IAM Policy for Lambda
resource "aws_iam_policy" "lambda_policy" {
name = "${var.environment_domain_slug}-lambda"
description = "Allows @ edge execution"
policy = jsonencode({
"Version" : "2012-10-17",
"Statement" : [
{
"Sid" : "VisualEditor0",
"Effect" : "Allow",
"Action" : [
"iam:PassRole",
"lambda:DisableReplication",
"lambda:GetFunction",
"cloudfront:ListDistributionsByLambdaFunction",
"lambda:DeleteFunction"
],
"Resource" : "*"
},
{
"Action" : [
"logs:CreateLogGroup",
"logs:CreateLogStream",
"logs:PutLogEvents"
],
"Effect" : "Allow",
"Resource" : "arn:aws:logs:*:*:*"
},
{
"Action" : [
"s3:GetObject",
"s3:ListBucket"
],
"Effect" : "Allow",
"Resource" : [
"*"
]
}
]
})
}
# Attach Policy to lambda Role
resource "aws_iam_role_policy_attachment" "lambda_policy_attach" {
role = "${var.environment_domain_slug}-lambda"
depends_on = [aws_iam_role.lambda_exec]
policy_arn = "arn:aws:iam::${var.account_id}:policy/${var.environment_domain_slug}-lambda"
}
```
There are a few areas I’d still like to improve. Chief among them are the IAM permissions used for S3 replication, which are currently more permissive than I’m comfortable with. I’ll need to spend some time digging further into the documentation to tighten these down to the minimum required scope.
That said, the process itself was genuinely enjoyable. Figuring things out as I went reinforced just how powerful Terraform can be, especially once you realize that resource blocks can be dynamic and that for_each expressions can thus be nested. At that point, the configuration becomes significantly less verbose and far less repetitive.
[Source on GitLab for the curious.](https://gitlab.com/edstuurman/terraform)
---
# Reducing compute capacity and retiring unnecessary workloads in a home-lab to optimize power consumption and improve overall efficiency.
- URL: https://www.stuurman.com/blog/2025-12-9-homelab-vmware-vsan-proxmox/
- Published Date: Dec 09, 2025
- Tags: VMware, Proxmox, Homelab
- Description: Downsizing compute and retiring unnecessary workloads in a homelab to cut power draw and optimize Proxmox and storage efficiency.
Coming from a background where I worked heavily with VMware, I was determined to build my own vSAN and converged compute environment. Before long, I had three hefty 4U servers, each loaded with generous amounts of RAM and powered by 24-core, energy-hungry processors. It was overkill in every sense, but at the time, it felt like the perfect home-lab dream.
The VMUG membership was the key enabler, costing roughly $200 annually providing VMware Advantage. This gave me access to a comprehensive suite of VMware licenses specifically intended for non-production environments.
Building and configuring the hardware was genuinely enjoyable. But when COVID arrived, our two-bedroom apartment didn’t exactly pair well with a full home-lab, and the living room effectively became a datacenter. After moving to a new house, I disassembled the cluster and never reassembled it. The fan noise, the SFP+ switch whine, the heat and power draw all made me hesitate to bring that entire setup back online.
Scope—something I mentioned at the beginning of this blog post—had slipped my mind over time. I had drifted away from the original goal and allowed the setup to grow far beyond what I actually needed. What I really wanted was a resilient storage platform for pictures and videos, nothing more. I didn’t need an entire cluster, and certainly not the full Gibson-sized infrastructure, to achieve that.
With that realization, the path forward is obvious: scale everything back. I’m consolidating the environment down to a single machine, something far simpler, quieter, and more energy-efficient. And honestly, one well-configured box is more than enough for the workload I have today.
I’ve also decided not to return to VMware. VMUG Advantage no longer aligns with my needs, so I’m shifting back to Proxmox and exploring what the platform can offer for a simplified, single node environment.
---
# Improving time to first byte, an intro to S3 Multi Region Access Points, Lambda@Edge functions and CloudFront
- URL: https://www.stuurman.com/blog/2025-12-6-aws-mrap-s3-lambda-cloudfront/
- Published Date: Dec 06, 2025
- Tags: AWS, Infrastructure
- Description: Improving Time to First Byte (TTFB) globally with S3 Multi-Region Access Points, Lambda@Edge, and CloudFront.
> [!WARNING]
> This article is functional but out of date.
> AWS MRAP behind cloudfront no longer requires a lambda function for signing requests.
It has been a few days since I wrote about the initial setup of this Astro site on: [AWS Amplify](https://www.stuurman.com/blog/2025-11-25-building-an-astro-site). Honestly, I expected that setup to carry me for a good while. But after a bit of tinkering and just a little experimentation I stumbled into an unexpected revelation…
```
2025-12-03T02:32:51.248Z [ERROR]: !!! CustomerError: The size of the build
output (257224425) exceeds the max allowed size of 230686720 bytes. Please
reduce the size of your build output
(/codebuild/output/src1544707934/src/path/.amplify-hosting/compute/default) and try again.
```
That’s right. Building the site with Amplify comes with a hard limit of roughly 200–220 MB of storage. Once you cross that threshold, the build simply fails. I knew I’d run into it eventually with [photography](https://www.stuurman.com/photography/) , but I managed to trigger it sooner than expected. All thanks to a few too many modules sneaking into the project. Go figure.
# And this is how I came to create a Rube Goldberg Cloud Hosting Machine.
Bottom line: I’ve moved the site to a setup using multiple geographically distributed S3 buckets for static hosting. Whenever I push or merge into the main branch, GitLab triggers a CI/CD pipeline that builds the site and uploads the output to one primary bucket. From there, the content is automatically replicated to all the other regional buckets.
All of these buckets sit behind an S3 Multi Region Access Point (MRAP), a global endpoint that automatically routes traffic to the lowest latency region and provides seamless failover. As far as I can tell, the permissions side of this should live squarely in IAM territory. However, there’s a gotcha: requests to a Multi Region Access Point need to be signed using the SigV4A signing mechanism when the traffic is coming through a CloudFront distribution. To handle that signing step, Lambda@Edge functions can be used to apply the appropriate request headers on the fly with the same low latency we have been optimizing for.
## This blog has origins in:
- Europe (Stockholm)
- Europe (Paris)
- Asia Pacific (Mumbai)
- Asia Pacific (Sydney)
- Asia Pacific (Tokyo)
- US West (N. California)
- US East (N. Virginia)
- South America (São Paulo)
## This has been done before.
There are plenty of articles out there that, in one form or another, rehash the official AWS guide: [Building an active-active, latency-based application across multiple Regions](https://aws.amazon.com/blogs/storage/building-an-active-active-latency-based-application-across-multiple-regions/) The [github](https://github.com/aws-samples/amazon-cloudfront-with-s3-multi-region-access-points) repo that accompanies it—amazon-cloudfront-with-s3-multi-region-access-points—includes CloudFormation templates and a Python Lambda function, but (as you might guess) it doesn’t exactly work out of the box. Tracking down the right dependencies and figuring out which Python version it was originally meant for is… not straightforward.
Along the way, I found another excellent deep dive: [Ultimate Guide to Multi-Region Access Points (MRAP)](https://cloudzenia.com/blog/ultimate-guide-to-multi-region-access-points-mrap/).
They wrote their own Node.js function, and although I was tempted to try it, I kept digging. That’s when I stumbled on the MVP [KIMBEOBWOO](https://github.com/KIMBEOBWOO/aws-cloudfront-with-s3-multi-region-access-point) I decided to give that one a shot. I removed the original Python functions and deployed a clean Node.js Lambda instead.
Immediately, the cryptic error messages that had been plaguing me turned into something actually grokable. The first complaint was about GetBucketList. One quick IAM adjustment later, and I could finally see the bucket contents. Navigating directly to an HTML file even rendered the page. Huge progress.
I was close. The function worked, but it didn’t remove the requirement to explicitly specify object names like index.html at the domain apex or inside subdirectories. A couple of small tweaks, basically two if statements fixed that behavior. And just like that, everything came together.
Case in point: you’re reading this right now, and unless CloudFront served it from cache, your request just passed through my Lambda@Edge function.
### My Humble Contribution
```js
const crt = require("aws-crt");
const { sigV4ASign } = require("./sigv4a_sign");
/**
* Lambda handler
* @param {*} event
* @returns SigV4 signed request
*
* @see https://github.com/aws-samples/amazon-cloudfront-with-s3-multi-region-access-points
* @author KIMBEOBWOO
*/
exports.handler = async (event) => {
const { request } = event.Records[0].cf;
let config = {
service: "s3", // Empty for GET, could be mapped from request, if there is such case. E.g. request['body']['data']
region: "*", // For S3 Multi-Region Access Point it's '*' (e.g. all regions). Also, that's why SigV4A is required.
algorithm: crt.auth.AwsSigningAlgorithm.SigV4Asymmetric,
signature_type: crt.auth.AwsSignatureType.HttpRequestViaHeaders,
signed_body_header: crt.auth.AwsSignedBodyHeaderType.XAmzContentSha256,
provider: crt.auth.AwsCredentialsProvider.newDefault(),
};
let method = "GET";
if (request.uri.endsWith('/')) {
request.uri += 'index.html';
}
// Check whether the URI is missing a file extension.
else if (!request.uri.includes('.')) {
request.uri += '/index.html';
}
let endpoint = `https://${request["origin"]["custom"]["domainName"]}${request["uri"]}`;
// CloudFront adds "X-Amz-Cf-Id" header after Origin request Lambda but before the request to the origin.
// Therefore it has to be part of the signing request.
let xAmzCfId = event["Records"][0]["cf"]["config"]["requestId"];
// Add SigV4A auth headers in the by CloutFront expected data structure.
let signedHeaders = sigV4ASign(method, endpoint, xAmzCfId, config)
._flatten()
.filter(([key]) => key.toLowerCase() !== "x-amz-cf-id")
.reduce((acc, [key, value]) => {
acc[key] = [{ key, value }];
return acc;
}, {});
// Add the signed headers to the request
request.headers = {
...request.headers,
...signedHeaders,
};
return request;
};
```
## In Closing
I didn’t want to spin up yet another plain static site on a lone S3 bucket. This new setup felt more interesting, and honestly, I’m pretty happy with how it turned out. It was a fun experiment, and I learned quite a bit along the way.
---
# Umami: What I Learned While Eating a Big Serving of Humble Pie.
- URL: https://www.stuurman.com/blog/2025-11-27-aws-dsql-amplify-umami-analytics/
- Published Date: Nov 27, 2025
- Tags: AWS, Lesson
- Description: Lessons learned exploring how cheaply I could self-host the open-source Umami analytics suite on AWS with Aurora DSQL and Amplify.
Umami is a modern, privacy-focused analytics platform. An open-source alternative to Google Analytics, Mixpanel and Amplitude.
Umami Analytics is primarily built with JavaScript and TypeScript, using the React based Next.js framework. While the core application runs on these web technologies, it relies on a database for storing analytics data, with PostgreSQL being the recommended and officially supported option.
There are plenty of guides showing how to deploy Umami on Vercel and Supabase as a privacy friendly and inexpensive alternative to Google Analytics. But I had a different idea in mind and, admittedly, got a bit stubborn about it: I wanted to run Umami on AWS and given how little traffic my site gets, I wanted to pay as close to $0.00 for it as possible.
Naturally, the plan was to contribute this fresh, undocumented implementation back to the community. But if you’re still reading, it’s worth clarifying: this isn’t a tutorial or a step by step guide. It’s more of a cobbled together concept. Something that might one day be revisited and developed into a proper solution, if it ever sees the light of day.
## Stacks I Considered:
First idea: run Umami’s Docker containers on AWS ECS. There are official Umami containers — great. But how low could I push the price if I deployed ECS on the smallest EC2 instance available? Answer: not very. Still a few dollars a month. Unacceptable.
Next idea: deploy the analytics front-end on AWS Amplify, a stack I already use and wanted to explore further. Perfect… except Umami needs a Postgres backend, and I wanted to keep everything AWS-native. I considered AWS RDS, but that was an order of magnitude more expensive than I was willing to pay. What I really needed was something like a serverless relational database. Basically DynamoDB but SQL-flavored.
Enter AWS DSQL. A relatively new service, Generally Available in multiple regions, and billed via Distributed Processing Units — which means no activity, no cost. Fantastic. I could host the frontend on Amplify and let DSQL handle the storage.
But then reality hit: DSQL doesn’t support simple user:password authentication. It relies on ephemeral tokens and IAM roles. That meant Amplify would need an Umami adapter, and Umami would somehow need to generate DSQL auth tokens at connection time.
And that’s where everything fell apart. Too many new concepts at once. Documentation was thin. I couldn’t find any real-world examples showing how to make this work. I did find a short presentation explaining how to generate DSQL tokens in an Amplify app, but the GitHub source for the demo was nowhere to be found.
## In Closing:
I shelved the idea and let it sit for a while. So much ado for nothing. I’m already chuckling at my own foolhardiness.
Back to more accessible things — like expanding the features on this site that are still missing.
---
# Running a Mail Server Is Cool. Until SPF, DKIM, DMARC, and blocklists remind you what it really costs.
- URL: https://www.stuurman.com/blog/2025-11-26-email-deliverability/
- Published Date: Nov 26, 2025
- Tags: Email, Infrastructure, Opinion
- Description: Why self-hosting email is no longer practical: SPF, DKIM, DMARC, blocklists, and the true operational cost of mail deliverability.
I want to back up my opinion with some context. Well over a decade ago, I fondly remember setting up Exim4 mail servers (MTA) and sending hundreds of thousands of transactional emails daily. Since then, I’ve spent several years working full-time with email systems and infrastructure. Even with that experience, I still consider myself a rookie. In both cases, one thing was clear: setting up the infrastructure is a fun and rewarding exercise, but deliverability and inbox placement are a grind.
You’re constantly just one noisy neighbor away from having part of your system blacklisted, and every receiving email platform enforces its own set of rules and policies. On top of that, many people treat the “mark as spam” button like an unsubscribe feature, just as one example. I could list every way running an email platform drains the fun out of what was once an enjoyable infrastructure project, but that would be a tome in itself.
The point is, whether you’re self hosting email for a single user or managing millions of inboxes, ensuring that every message actually arrives at its destination is a 24/7 job. I don’t know anyone who would call this a hobby.
And this is why I use an email provider like [FastMail](https://www.fastmail.com/). Their onboarding is smooth and straightforward, requiring only a few DNS records to get SPF, DKIM, and DMARC fully validated. They provide reliable email infrastructure and handle all the maintenance and support, making email virtually hassle free. All of this and much more comes at a modest, reasonable sum.
## Conclusion
Email deliverability is complex because it’s governed by evolving algorithms, authentication protocols, reputation systems, and provider specific rules. A service like [FastMail](https://www.fastmail.com/) can simplify all of this by offering dedicated infrastructure, automated best practices, monitoring, and support designed to maximize inbox placement.
---
# I Also Fell Down the Rabbit Hole: Building a Fully Automated Astro Blog Pipeline with VSCode, GitLab, Amplify and CloudFront.
- URL: https://www.stuurman.com/blog/2025-11-25-building-an-astro-site/
- Published Date: Nov 25, 2025
- Tags: AstroJS, AWS, Infrastructure, Tailwind CSS
- Description: Because every infrastructure engineer eventually builds yet another automated static site pipeline.
I’ll be honest: I’m new to this whole blogging thing, but if there’s one universal truth about tech blogs, it’s that the first post is always about the stack behind it. So here’s mine. Don’t worry, I’ll spare you a 20-page deep dive. If I didn’t, this post would quickly devolve into a copy-and-paste marathon from half a dozen documentation pages, and nobody wants that.
## What I use
- VScode
- Astro js + tailwind css
- GitLab
- AWS Amplify
- AWS CloudFront
VSCode is my preferred IDE. It’s simple to use, free, and has a massive library of add-ons. Since this is my first Astro project, I’m especially grateful for the extension that provides proper syntax and semantic highlighting.
I ended up choosing Astro after comparing it to static site generators (SSG) I’ve used in the past, like Jekyll, Hugo, and Ghost. Astro is the shiny new kid on the block and brings some interesting features right out of the box, including static, hybrid, and server-side page rendering (SSR). If that piques your curiosity, I encourage you to check out their documentation or spin up an install yourself.
VSCode and Git work hand in hand. After setting up a private repository for this project, pushing changes is as simple as running a few commands in the terminal. I haven’t felt the need for extra extensions like GitLab Workflow but who knows, maybe future me will appreciate the convenience. I chose GitLab because I’m already familiar with self-hosted instances, so it felt like the natural fit for this project.
Enter AWS Amplify. Honestly, Amazon could hardly have made deploying apps any easier. Amplify can trigger a pull from your Git repository using a simple webhook whenever a branch is merged or a new commit gets pushed. Add a bit of YAML, and you’ve got a fully automated build process. From there, it’s almost absurdly straightforward: write a new post or tweak a file, push it, and let the magic cloud machinery handle the rest.
Lastly, with just a few extra clicks, a domain already managed in Route 53 can be automatically configured to front the Amplify app through AWS CloudFront. If you’re using a third-party DNS provider, you’ll probably have to add the required records manually. As a nice bonus, CloudFront also generates and configures an SSL certificate during the setup process.
## Conclusion
This is the high-level overview without diving too deep into the specifics. Those details are all well documented in their respective corners of the internet. Building this site from almost scratch and automating its deployment turned out to be a genuinely fun exercise, and a great way to explore a new stack end to end.
---
# Operational Logs & Micro-Updates
## Micro-Update: Considering an open-source Astro theme & infrastructure blueprint
- URL: https://www.stuurman.com/log/#2026-11-01-open-source-astro-theme-and-infra-blueprint
- Date: Nov 01, 2026
- Tags: AstroJS, Tailwind CSS, Infrastructure, CI/CD
At this point, I have customized and reworked the site's Tailwind CSS components and Astro templates far enough beyond their starting point that they have effectively evolved into a standalone theme. I am considering packaging and publishing the theme for free as an open-source starter—a practical way to share the design system and generate organic interest around the engineering behind it.
The real value would come from pairing the frontend template with turnkey deployment automation and a concise setup guide for the underlying cloud delivery stack, allowing anyone to provision the same globally distributed, high-performance static infrastructure out of the box.
---
## Micro-Update: LinkTap irrigation runs & retaining wall planting
- URL: https://www.stuurman.com/log/#2026-10-25-garden-irrigation-planning
- Date: Oct 25, 2026
- Tags: Hardware, IoT
Mapped out trench paths for water line drops to all four corners of the yard to pair with LinkTap wireless automated timers, plus dedicated conduit runs for low-voltage lighting and power.
Evaluating creeping ground-cover varieties to suppress weeds along the seams of a dry-stacked rock retaining wall, and weighing star jasmine against pyracantha for perimeter fencing.
---
# Site Changelogs
## Changelog: [undefined] - Client Bundle Diet, OG Image Build Cache & Dynamic Card Balancing
- URL: https://www.stuurman.com/log/#changelog-2026-11-01
- Date: Nov 01, 2026
### Added
- **Build-Time Static Pagination for Blog, Log & Photography**:
- Built a reusable static pagination system (`PaginationControls.astro`, `pagination.ts`) generating dedicated S3-compatible HTML routes (`/page/2/`, `/page/3/`, etc.) with zero SSR and no hidden off-page DOM bloat.
- Added pagination under the **Older blog posts** timeline on [`/blog/`](/blog/) (`BlogIndexView.astro`) with a 20-article per-page cutoff, rendering the 3 featured article cards exclusively on the initial index view while subsequent `/blog/page/N/` pages display strictly the older-articles timeline cards.
- Paginated [`/log/`](/log/) (`LogIndexView.astro`, `logTimeline.ts`) at 20 entries per page across both the unified stream ([`/log/page/2/`](/log/page/2/), [`/log/page/3/`](/log/page/3/)) and dedicated static category routes ([`/log/notes/`](/log/notes/), [`/log/changelogs/`](/log/changelogs/), [`/log/photos/`](/log/photos/)), with build-time deep-link page resolution across feeds, tags, and gallery captions.
- Added pagination under the gallery grid on [`/photography/`](/photography/) (`PhotographyView.astro`) with a 50-photograph per-page cutoff and cross-page `?photo=ID` deep-link resolution.
- **Homepage Section Footer Links & Log Category Badges**:
- Added navigation links beneath the 3-card grids in the "From the Photo Roll", "From the Blog", and "From the Log" sections on the homepage (`index.astro`), linking to the full photo gallery on [`/photography/`](/photography/), the older blog posts timeline on [`/blog/#older-posts`](/blog/#older-posts), and the full log stream on [`/log/`](/log/).
- Added category badges (`Micro-Update`, `Changelog`, and `Picture Log`) with matching icons next to the publication date above each homepage log card, added matching category icons to the `All`, `Micro-Updates`, `Changelogs`, and `Picture Logs` filter buttons on [`/log/`](/log/), and updated the site footer attribution line (`Footer.astro`) with links to [`https://www.stuurman.com`](https://www.stuurman.com) and [`https://astro.build/`](https://astro.build/) alongside the Astro logo mark.
- **LLM Discoverability: Build-Time `llms.txt`, `llms-full.txt` & `llms.json` Endpoints**:
- Replaced static LLM text files with build-time generated [`/llms.txt`](/llms.txt) (`llms.txt.ts`), [`/llms-full.txt`](/llms-full.txt) (`llms-full.txt.ts`), and [`/llms.json`](/llms.json) (`llms.json.ts`) endpoints that read directly from the content collections, career history, peer recommendations, operational logs, changelogs, and photography metadata so all three stay automatically synchronized on every build.
- Linked [`/llms.txt`](/llms.txt), [`/llms-full.txt`](/llms-full.txt), and [`/llms.json`](/llms.json) from a dedicated row in the footer's "Index & Feeds" column and advertised all three through `` tags in every page ``.
- **Content-Addressed Persistent OG Image & Astro Asset Disk Cache (`generateOgImage.ts`)**:
- Implemented a SHA-256 content-addressed disk cache (`.cache/og-images/`) for Satori + Resvg 1200×630 Open Graph and Twitter summary card generation across all 140 static routes, keying cache entries on both card metadata and a hash of the generator template source so visual template updates automatically invalidate stale assets while warm production builds drop from ~60s to ~1.5s.
- Relocated Astro's image optimization cache (`cacheDir: './.cache/astro'`), the OG image cache (`.cache/og-images/`), and the npm tarball cache (`.npm/`) outside `node_modules/` so `npm ci --cache .npm --prefer-offline` in GitLab CI preserves all 284 responsive WebP photo variants and 140 OG cards across pipeline runs.
- **Content-Hash Incremental S3 Deployment & Targeted CloudFront Invalidations**:
- Replaced blanket `aws s3 sync` (which re-uploaded all 592 files / 58 MB on every CI run due to fresh build `mtime` timestamps) with a content-hash deployment script (`s3-deploy.sh`) that compares local MD5 digests against S3 object ETags and only uploads genuinely new or modified files, cutting redundant S3 `PUT` operations and MRAP cross-region replication traffic while issuing targeted CloudFront invalidations.
- **Build-Time Technical Tooltip Rehype Compiler (`rehypeTechTooltips`)**:
- Built a custom Rehype markdown plugin (`rehype-tech-tooltips.mjs`) that compiles semantic `` elements directly into static `.tech-tooltip-wrapper` markup at build time (while keeping single-line raw tooltip spans invisible to heading slug extraction), eliminating the 20 KB client-side dictionary payload and runtime DOM wrapping while preserving interactive viewport boundary clamping, mobile tap-to-toggle, and keyboard dismissal in `tech-tooltips.ts`.
- **Dynamic Featured Blog Card Height Balancing**:
- Implemented an adaptive, client-side paragraph fitting engine on [`/blog/`](/blog/) that dynamically balances the introductory preview of the newest featured article to precisely match the rendered height of the right-column cards.
- Automatically calculates available vertical space down to the card footer, displays candidate body paragraphs, and gracefully clamps the final visible paragraph to whole lines with `-webkit-line-clamp`, eliminating awkward empty whitespace.
- Integrated `ResizeObserver` on the right column container alongside window resize and page transition handlers, while preserving a clean, compact 2-paragraph preview on mobile viewports and SSR/no-JS environments.
- **Mermaid Diagram Accessibility Plugin (`rehypeMermaidA11y`)**:
- Built a custom Rehype markdown plugin that intercepts Mermaid-generated SVG diagrams at build time, replacing invalid composite abstract roles (`role="graphics-document document"`) with standard `role="img"`, adding `aria-label="Mermaid diagram"`, and normalizing role descriptions.
### Changed
- **Clean-Room MIT UI Architecture & Custom Zero-Dependency Photo Lightbox**:
- Rewrote all structural page layouts and decorative UI primitives from scratch for full MIT open-source readiness, replacing legacy polygon clip-path blobs with multi-layered CSS `radial-gradient` atmospheric meshes (`AmbientGlow.astro`), rebuilding the top header and mobile disclosure drawer (`Navigation.astro`), flattening the directory footer grid (`Footer.astro`), restructuring the peer recommendations bento grid on [`/about/`](/about/) (`about.astro`), and modernizing the featured/three-column card shells across [`/blog/`](/blog/) (`BlogIndexView.astro`), the homepage (`index.astro`), the 404 status page (`404.astro`), and article headers (`BlogPost.astro`).
- Replaced `astro-lightgallery` and its transitive GPLv3 `lightgallery` runtime on [`/photography/`](/photography/) with a custom, zero-dependency justified aspect-ratio flex gallery and accessible `#photo-lightbox` viewer (`PhotographyView.astro`), featuring interactive zoom, fullscreen toggle, scrollable thumbnail filmstrip, keyboard (`←`/`→`/`Esc`) and touch-swipe navigation, Picture Log deep links, sort-order toggling, and `?photo=ID` cross-page resolution.
- **Global Client JavaScript Diet & Dependency Cleanup**:
- Replaced `@tailwindplus/elements` (``, 82 KB global `` bundle) with an accessible, zero-dependency vanilla disclosure controller in `Navigation.astro` managing `aria-expanded`, `hidden` state, icon swapping, and `Escape` dismissal, reducing global client JavaScript across every page by ~95% (~107 KB down to ~5 KB).
- Pre-indexed technical glossary definitions into an `O(1)` lowercase lookup `Map` in `techTerms.ts`, precomputed career skill tag sets in `experiences.ts`, eliminated full-DOM `MutationObserver` thrashing in `border-beam.ts`, and removed unused `moment`, `astro-toolkit`, and `@tailwindplus/elements` packages.
- **Unified Content Pipelines, Sitemap `lastmod` Precision & Reusable UI Primitives**:
- Consolidated RSS ([`/rss.xml`](/rss.xml)) and Atom ([`/atom.xml`](/atom.xml)) syndication builders onto a single memoized 4-collection pipeline (`feedItems.ts`), and centralized cross-collection tag taxonomy aggregation (`tagTaxonomy.ts`) across [`/tags/`](/tags/), topic archive pages, Open Graph card generation, and career skill badges.
- Replaced blanket build-timestamp sitemap `lastmod` values with authentic per-route modification timestamps (`sitemap-images.mjs`) derived from Open Graph article metadata, embedded `