$ sudo make vibe

171+ CVEs, Astro 4 to 6, Zero Downtime

· 7 min read

I opened the terminal to patch CVEs. I ended up migrating from Astro 4 to Astro 6, pinning Node to a new LTS release, and shipping a cleaner stack than the one I started the day with. The site never went down.

This is what that actually looked like.

Pre-upgrade audit findings on the ThinkBookThe actual state of the publishing stack on May 14 2026: Ubuntu 24.04 ready to patch, Node v25.9.0 non-LTS needing migration to v24, and Astro 4.16.18 two majors behind.Layer 1 — Ubuntu 24.04 LTSkernel 6.17.0-23 current · May 11 batch ready to apply25 packages pending · safe to upgrade, no Astro dependencyLayer 2 — Node.js ⚠ debt foundrunning v25.9.0 via nvm · non-LTS, EOL Oct 2026migrate to v24 LTS, supported until April 2028Layer 3 — Astro project ⚠ debt foundrunning Astro 4.16.18 · two majors behindmigrate to 6.3.3 · Content Layer API now mandatoryThree-session planSession 1 — sudo apt upgrade · verify build still greenSession 2 — nvm install 24, set default · verify build still greenSession 3 — Astro 4→6 on git branch · merge only when greenzero downtime · isolation model means each session is independently verifiable

The CVE that started it

On May 11, 2026, Canonical pushed a significant security patch batch for Ubuntu 24.04 LTS — 171+ CVEs addressed in a single update cycle. One of them was CVE-2025-38352, a local privilege escalation vulnerability being actively exploited in the wild.

On a homelab machine running Kali tooling alongside a web development environment, that is not an advisory to read and file. It is something to patch the same day.

The intent was simple: sudo apt update && sudo apt upgrade, verify nothing broke, move on. But good practice says you audit before you touch anything. So before running the upgrade, I checked the current state of the stack.

That audit is where things got interesting.

The three-layer model

Before getting into what was found, it helps to understand how Ubuntu, Node.js, and an Astro project actually relate to each other on a development machine. Most developers treat these as one interconnected system. They are not.

They are three independent layers with narrow bridges between them.

Layer 1 — Ubuntu system packages. Managed by apt. Lives in /usr/, /lib/, /etc/. Covers the kernel, firmware, system libraries, desktop environment, and anything installed via sudo apt install. An apt upgrade can only touch what apt owns.

Layer 2 — Node.js runtime. When installed via nvm (Node Version Manager), Node lives entirely in ~/.nvm/ under your user account. The apt package manager has no visibility into it. A kernel patch cannot touch your Node version. The only bridge between Layer 1 and Layer 2 is you, running nvm install or nvm use deliberately.

Layer 3 — Project dependencies. Everything in node_modules/ inside your project directory. Managed by npm, locked to package-lock.json. Node.js is the only runtime that executes these packages. apt cannot reach them. The only bridge between Layer 2 and Layer 3 is the Node version — specifically whether your Node version satisfies the peer dependency requirements of your framework.

Three-layer isolation modelThree independent layers: Ubuntu system packages, Node.js runtime, and project dependencies, each managed by separate tools with narrow bridges between them.Layer 1 — Ubuntu system packagesapt · kernel, firmware, glibc, system libs · /usr/ /lib/ /etc/only bridge: nvm installLayer 2 — Node.js runtimenvm · ~/.nvm/ · apt has zero visibility hereonly bridge: Node version compatibilityLayer 3 — Project dependenciesnpm · node_modules/ · locked to package-lock.jsonapt upgrade cannot reach Layer 2 or Layer 3 — isolation is structural, not configured

This model matters because it tells you exactly what an apt upgrade can and cannot break. It cannot touch your Astro version. It cannot touch your npm packages. It can only affect system-level components. If your project builds clean before the upgrade and Node is managed via nvm, the upgrade is safe by design.

The pre-upgrade audit confirmed the isolation was intact. But it also surfaced two things that had nothing to do with the Ubuntu patch.

What the audit found

Running the pre-upgrade checks:

node --version
# v25.9.0

cat package.json | grep '"astro"'
# "astro": "^4.16.18"

npm run build
# 40 page(s) built in 2.33s — Complete!

Two issues, neither caused by Ubuntu.

Node v25.9.0 is not an LTS release. The v25 line is Node’s “current” release track — end-of-life around October 2026. Running a non-LTS Node version on a production publishing machine is quiet technical debt. The current LTS is v24, supported until April 2028.

Astro 4.16.18 is two major versions behind. Astro 6 went stable on February 10, 2026. The site launched April 20, 2026 — built on Astro 4, which was already behind at launch. Not broken, but not current. Astro 6 ships a mandatory Content Layer API migration, a Zod major version bump, and Shiki v4. None of these were blocking the site, but all of them would need to be resolved eventually.

The CVE patch became a full stack audit. Three sessions, one sitting.

Session 1 — patch the system

sudo apt update && sudo apt upgrade

25 packages upgraded. The notable ones: linux-firmware, gnome-shell, thermald, iproute2, google-chrome-stable, and the full remmina suite.

The kernel itself did not update. The kernel at 6.17.0-23-generic was already current — the May 11 batch was a firmware and userspace patch cycle, not a kernel swap. The initramfs regenerated against the existing kernel as expected.

Post-upgrade build verification:

npm run build
# 40 page(s) built in 2.14s — Complete!

Faster than the baseline. Isolation confirmed.

Session 2 — pin Node to LTS

nvm install 24
nvm alias default 24
nvm use 24
node --version
# v24.15.0

Node v24.15.0 installed, set as nvm default. npm bumped to 11.12.1 as part of the Node upgrade. A .nvmrc file committed to the repo root pins the version for future sessions and documents the expectation in version control.

Post-upgrade build verification:

npm run build
# 40 page(s) built in 2.21s — Complete!

Clean on v24.

Session 3 — migrate to Astro 6

This was the session with actual work. Astro 6 removes the backwards compatibility grace period that Astro 5 had introduced for the Content Layer API. Migration is mandatory, not optional.

The full change inventory:

  • @astrojs/tailwind removed — not compatible with Astro 6. Replaced with direct PostCSS configuration; Tailwind v3 continues running unchanged.
  • src/content/config.ts moved to src/content.config.ts — file moved to project root inside src/, collection definition rewritten with the new glob() loader.
  • defineCollection stays imported from astro:content. z moves to astro/zod.
  • entry.render() replaced with render(entry) with named import throughout all dynamic route files.
  • post.slug replaced with post.id.replace(/\.md$/, '') — the Content Layer API returns file-based IDs with extensions rather than clean slugs. A simple replace preserves existing URL structure exactly.
  • post.body removed — no longer available in the Content Layer API. Reading time falls back to the readingTime frontmatter field, set manually on every post.
  • autoprefixer added as an explicit dev dependency — was previously bundled inside @astrojs/tailwind.

One note for anyone running a Wayland session: heredoc file writes are unreliable when your terminal has a Markdown copy button. Clipboard corruption strips HTML tags mid-block silently — the write appears to succeed but the file is broken. The reliable alternative is Python targeted replacements, always followed by a grep verification:

python3 -c "
content = open('src/pages/blog/index.astro').read()
content = content.replace('old string', 'new string')
open('src/pages/blog/index.astro', 'w').write(content)
print('done')
"

The migration ran on a dedicated branch — astro-v6-upgrade — merged to main only after a clean local build. Vercel auto-deploys on push to main. The branch discipline is not optional.

Post-migration build verification:

npm run build
# 40 page(s) built in 2.31s — Complete!

The full picture

StateNodeAstroPagesBuild time
Pre-upgrade baselinev25.9.04.16.18402.33s
Post apt upgradev25.9.04.16.18402.14s
Post Node v24 pinv24.15.04.16.18402.21s
Post Astro 6 migrationv24.15.06.3.3402.31s

Three state changes. Build time stable throughout. Zero regressions. The isolation model worked exactly as the theory predicted.

What this means for your homelab publishing stack

If you run a static site on a Linux development machine, the three-layer model is worth internalising before your next patch cycle.

apt upgrade is safe to run without checking your project dependencies first — as long as Node is managed via nvm and not installed via apt. If you installed Node through apt, the bridge between Layer 1 and Layer 2 collapses and an upgrade can silently change your Node version mid-project.

Check your Node version against the LTS schedule before every major patch cycle. The Node release calendar is at nodejs.org/en/about/releases. If your version shows EOL within six months, migrate now rather than during an incident.

Run a build verification before and after every system upgrade. Not to check the upgrade — to establish a known-good baseline. If something breaks three weeks later, you want to know the last confirmed clean state.

The version you launch on is not the version you stay on. Astro 4 was a reasonable choice in April. Astro 6 is the current production line. The gap between those two states accumulates quietly until a CVE patch day forces the audit.

That is the real value of treating a routine security update as a full stack review. The CVEs get patched. The debt gets surfaced. The stack ends up cleaner than it started.

One more thing: the slug bug

The build was green. The Vercel deploy showed Ready. The post was live. Then the homepage showed every post card linking to /blog/undefined.

This was not caught by the build because Astro’s static build does not validate that prop values are defined — it just renders whatever it receives. undefined is a valid JavaScript value, so the build succeeds and the HTML contains href="/blog/undefined" silently.

The root cause is a breaking change in Astro 6’s Content Layer API. In Astro 4, every collection entry had a slug property derived automatically from the filename. In Astro 6 that property is gone. Entries now have an id property that includes the file extension — my-post.md instead of my-post.

Any component or page that passes post.slug to a link will silently produce undefined URLs. The fix is a one-line replacement everywhere post.slug is used:

post.slug
# becomes
post.id.replace(/\.mdx?$/, '')

The migration had patched [...slug].astro and blog/index.astro correctly. But three other files had the same pattern and were missed:

  • src/pages/index.astro — homepage latest posts section
  • src/pages/tags/[tag].astro — tag listing pages
  • src/pages/rss.xml.js — RSS feed

The lesson: when migrating to the Content Layer API, do a full codebase search for post.slug before declaring the migration complete:

grep -rn "post\.slug" src/

Any result that is not inside a Markdown content file is a bug waiting to ship.

$ ls ~/palettes four themes available, switch at the footer ↓