diff --git a/.cache/pages.json b/.cache/pages.json
index 83133af4..f82a2e15 100644
--- a/.cache/pages.json
+++ b/.cache/pages.json
@@ -37,6 +37,7 @@
"kubernetes-hpa-autoscaling-metrics-tuning-latency",
"kubernetes-ingress-gateway-api-comparison-migration",
"kubernetes-multi-cluster-fleet-management-configuration",
+ "kubernetes-pod-disruption-budget-autoscaler-node-rotation",
"kubernetes-pod-resource-requests-limits-qos-classes",
"kubernetes-secrets-external-secrets-operator-csi-vault",
"legacy-code-testing-characterization-tests-seams",
@@ -87,75 +88,6 @@
},
"consent",
"contact",
- {
- "deep-dive": [
- "alert-fatigue-reduction-triage-actionable-alerts",
- "api-deprecation-sunset-headers-consumer-migration",
- "api-gateway-metrics-traces-logs-debugging",
- "api-usage-metering-quotas-cost-attribution",
- "argocd-sync-failures-gitops-debugging-troubleshooting",
- "availability-targets-five-nines-cost-benefit-analysis",
- "backpressure-load-shedding-admission-control-overload",
- "blameless-postmortem-incident-analysis-systemic-causes",
- "blue-green-canary-deployment-strategy-comparison",
- "cdn-edge-caching-cache-keys-vary-headers",
- "chaos-engineering-failure-injection-low-cost-experiments",
- "ci-pipeline-caching-docker-layers-dependency-cache",
- "circuit-breaker-retry-budget-cascade-failure-prevention",
- "consumer-driven-contract-testing-pact-internal-apis",
- "container-vulnerability-scanning-ci-shift-left-security",
- "database-schema-migrations-continuous-deployment-zero-downtime",
- "dead-letter-queue-design-replay-debugging",
- "distributed-tracing-sampling-strategies-head-tail",
- "eol-runtime-upgrade-dependency-hell-migration",
- "ephemeral-preview-environments-cost-control-cleanup",
- "flaky-test-diagnosis-race-conditions-e2e-stabilization",
- "golden-paths-developer-experience-standardization-autonomy",
- "grafana-dashboard-hygiene-pruning-actionable-metrics",
- "helm-release-management-drift-detection-debugging",
- "idempotent-message-handlers-deduplication-retries",
- "internal-cli-kubectl-terraform-wrapper-abstraction",
- "internal-developer-portal-platform-self-service-actions",
- "internal-platform-api-versioning-deprecation-breaking-changes",
- "kubernetes-cluster-upgrade-playbook-risk-reduction",
- "kubernetes-cost-optimization-resource-sizing-spot-instances",
- "kubernetes-decision-framework-when-not-to-use",
- "kubernetes-dns-debugging-ndots-coredns-troubleshooting",
- "kubernetes-hpa-autoscaling-metrics-tuning-latency",
- "kubernetes-ingress-gateway-api-comparison-migration",
- "kubernetes-multi-cluster-fleet-management-configuration",
- "kubernetes-pod-resource-requests-limits-qos-classes",
- "kubernetes-secrets-external-secrets-operator-csi-vault",
- "legacy-code-testing-characterization-tests-seams",
- "monorepo-affected-builds-remote-caching-ci-optimization",
- "mtls-certificate-rotation-service-mesh-authentication",
- "nginx-haproxy-reverse-proxy-production-tuning",
- "on-call-rotation-small-teams-sustainable-coverage",
- "opa-conftest-policy-as-code-infrastructure-guardrails",
- "openapi-spec-documentation-sdk-generation-validation",
- "opentelemetry-span-design-granularity-overhead",
- "performance-testing-load-models-benchmark-accuracy",
- "platform-architecture-control-plane-data-plane-separation",
- "platform-engineering-metrics-lead-time-developer-friction",
- "postgresql-connection-pooling-saturation-sizing",
- "private-networking-dns-routing-tls-debugging",
- "prometheus-high-cardinality-metrics-label-design",
- "rate-limiting-token-bucket-leaky-bucket-implementation",
- "release-quality-gates-automated-deployment-validation",
- "reverse-engineering-documentation-legacy-systems",
- "service-catalog-metadata-schema-ownership-tracking",
- "service-decommissioning-scream-test-shutdown",
- "slo-error-budget-practical-guide",
- "slsa-build-provenance-artifact-signing-supply-chain",
- "strangler-fig-migration-complete-guide",
- "structured-logging-correlation-ids-log-schema-design",
- "symptom-based-alerting-runbooks-alert-design",
- "synthetic-test-data-pii-anonymization-fixtures",
- "terraform-module-design-defaults-versioning-interfaces",
- "terraform-state-locking-corruption-recovery-backend",
- "workload-identity-federation-keyless-cloud-authentication"
- ]
- },
"newsletter",
{
"privacy": [
diff --git a/_TODO.md b/_TODO.md
index 6c223a79..700d34e4 100644
--- a/_TODO.md
+++ b/_TODO.md
@@ -26,10 +26,6 @@ https://aws.plainenglish.io/how-to-build-a-chatbot-using-aws-lex-and-lambda-in-2
- Need to improve the "squish" animation where the header reduces in size on scroll down, and returns to full size on scroll up. Maybe reduce and expand the text and search / themepicker / hamburger menu sizes in place, and then slide them horizontally.
- Themepicker and search icon are too big in non-squished header. Logo too - the initial presentation should be smaller.
-## Resume
-
-- Finish styling
-
## Contact Form
- `0/2000` characters should show number of characters left instead
@@ -39,342 +35,3 @@ https://aws.plainenglish.io/how-to-build-a-chatbot-using-aws-lex-and-lambda-in-2
- Need to move the unsubscribe link into an Action and handle it entirely within our website instead of on Hubspot
- Need to add a newsletter publishing workflow as an action, using the newsletter static segment imported from Hubspot
-
-## Performance Issues
-
-### FOUC / Setting Theme Styling
-
-Yes, the concern is legitimate. A theme flash is real, especially when the default HTML ships as light and the user actually wants dark or a custom theme. But the current solution is too expensive: it avoids a theme flash by forcing a blank-screen flash instead, which is worse for both users and Lighthouse.
-
-The important distinction is this:
-
-Preventing the wrong theme from painting is a good goal.
-
-Hiding the entire body until DOMContentLoaded is an overcorrection.
-
-In your current setup, BaseLayout.astro:140 hides the page, and ThemeInit.astro:105 waits until DOMContentLoaded on first load before removing that hidden state. That means the browser is ready to paint, but your code tells it not to.
-
-The better pattern is to set the theme before first paint, not to suppress paint. Usually that means:
-
-- Put a very small inline script in
.
-- That script runs immediately, not on DOMContentLoaded.
-- It only does the minimum: read localStorage if available, fall back to matchMedia('(prefers-color-scheme: dark)'), and set document.documentElement.dataset.theme.
-- Do not touch body at all.
-- Do not wait for the DOM to finish parsing.
-
-That works because document.documentElement already exists while the head is parsing. You do not need the body to exist in order to set the theme token that your CSS reads.
-
-There are also better structural ways to reduce or eliminate mismatch:
-
-For explicit user theme choices, store the selected theme in a cookie, not only localStorage. Then the server can render the correct data-theme on the initial HTML. This is the strongest fix.
-
-For plain light/dark system preference, use CSS prefers-color-scheme where possible. That lets the browser choose the right theme without JavaScript.
-
-If you need multiple custom themes, use a hybrid:
-
-- server reads a cookie for explicit chosen theme
-- client falls back to a tiny head script for first-time visitors using system preference
-Add so browser UI elements align better during startup.
-
-The practical conclusion is: your concern is valid, but hiding the page is not the right tradeoff. The usual fix is a two-phase approach:
-
-- Pre-paint phase: tiny inline head script sets the html theme immediately.
-- Post-load phase: optional richer theme logic updates meta color, event listeners, analytics, stores, and transition behavior.
-
-That gives you the theme correctness you want without sacrificing first paint.
-
-If you want, I can sketch the safest refactor path next without changing code yet, or I can go implement the minimal pre-paint split directly.
-
-Implemented the pre-paint theme fix.
-
-The change is in three places. In src/components/Head/index.astro, the theme init script now runs after the head metadata is emitted but still before the body is parsed. In src/components/Head/ThemeInit.astro, the script now resolves the theme and writes data-theme to `` immediately, without waiting for DOMContentLoaded and without touching body visibility. In src/layouts/BaseLayout.astro, the invisible class was removed from ``.
-
-This keeps the legitimate part of the old design, setting the correct theme before first paint, but removes the blank-screen gate that was delaying FCP and LCP. The script still populates window.metaColors, still updates the theme-color meta tag on initial load, and still handles Astro view transitions.
-
-## Secondary Contributors
-
-- The page is also heavier than it needs to be on first load: Lighthouse shows 85 requests and about 676 KiB transferred. The biggest buckets are 50 script requests / 194 KiB, 25 image requests / 287 KiB, and 3 font requests / 119 KiB.
-
-- The homepage in index.astro pulls in a lot of sections at once. The report shows several carousel cover images loading during initial navigation, and those covers come from index.astro:137.
-
-- The Backstage section also ships a large homepage image from index.astro:39, and that request is the single largest network item in the report at about 120 KiB.
-
-- Fonts are not the main regression because fonts.css:40 uses font-display: swap, but they still add noticeable startup weight.
-
-### Home page size
-
-- Audit the homepage hydration/chunk fan-out after prerendering. The 22 JS chunks suggest too much client code is shipping for a marketing landing page.
-
-No edits made. This is an audit of the current built homepage.
-
-The prerendered homepage still ships 22 direct module scripts from index.html:99, index.html:181, index.html:193, and index.html:230. The direct script total is 286,449 bytes before any imported shared chunks. That means the real network cost is higher, because these entries pull shared runtime pieces like Embla, Lit helpers, custom-element helpers, and GSAP.
-
-Roughly 165 KB of the direct JS is shell cost, not homepage content. The main driver is the always-on bootstrap from index.astro:64, which becomes the 101,517-byte top chunk and initializes consent, animation lifecycle, header search, and layout side effects from the bootstrap module. On top of that, the layout always includes BaseLayout.astro:134, BaseLayout.astro:144, BaseLayout.astro:147, BaseLayout.astro:190, and BaseLayout.astro:191. Header search and nav add more fixed cost through index.astro:62, index.astro:70, index.astro:151, and index.astro:179. The marketing homepage is paying for a full app shell.
-
-The biggest homepage-specific cost is the Terraform hero animation, not the tiny hero text effect. The 80,510-byte entry is emitted before the hero markup in index.html:185, and the animation is registered from index.astro:605 with a GSAP-heavy client in index.ts:149. The actual hero custom element registration is separate and small in index.astro:95.
-
-The newsletter block fans out into three separate direct entries. The form itself registers in index.astro:83, while the nested form content also pulls index.astro:30 and index.astro:93. You can see the three adjacent newsletter-related script entries in index.html:193 and index.html:212. Combined direct cost there is about 28 KB before shared imports.
-
-The carousel family is the next meaningful page-local contributor. Skills, latest insights, and testimonials each register separate custom elements in index.astro:125, index.astro:279, and index.astro:186. Their direct entry files are not individually huge, but they multiply the request graph and pull shared Embla infrastructure.
-
-The current use of whenIdle is not reducing chunk fan-out. It delays registration work, but it does not stop Astro from emitting direct module script tags into the prerendered HTML. That is visible for index.astro:88, index.astro:605, index.astro:279, and index.astro:186, and those scripts still show up in index.html:181 and index.html:193. So it helps execution timing, not shipped JS count.
-
-The highest-leverage reductions are clear. First, remove or aggressively defer the Terraform animation. Second, split the marketing homepage off the full app shell so it does not inherit ClientRouter, search, theme picker, bug reporter, and other global interactive infrastructure by default. Third, flatten the newsletter block into a simpler home-page variant without confetti and custom checkbox logic unless the user actually interacts.
-
-1. I can turn this into a concrete implementation plan with estimated byte savings per change.
-
-2. I can make the highest-value first cut now: remove or defer the Terraform animation and rebuild to remeasure.
-
-3. I can create a lighter marketing layout for the homepage and remeasure the shell cost.
-
-I would not start from PageLayout.astro:1, because it still inherits BaseLayout.astro:1. The clean version is a new marketing-only layout that sits much closer to PrintLayout.astro:1: same CSS pipeline and meta surface, but without the app shell.
-
-What that would look like in practice:
-
-- Keep the global CSS imports and the document skeleton from PrintLayout.astro:1.
-- Keep the meta tags from index.astro:1, but split them so the homepage does not automatically pull index.astro:64.
-- Drop BaseLayout.astro:134, BaseLayout.astro:144, BaseLayout.astro:147, BaseLayout.astro:194, BaseLayout.astro:190, BaseLayout.astro:191, and BaseLayout.astro:192 for the first pass.
-- Replace the current interactive header and footer with static marketing variants, because the current header/footer bring search, theme toggle, footer custom-element registration, and bug reporter behavior through index.astro:1 and index.astro:1.
-- Leave the homepage content in index.astro:1 unchanged at first, so the measurement isolates layout-shell savings before you touch hero, carousels, or newsletter behavior.
-
-Conceptually, it would be closer to this:
-
-```markdown
----
-import '@styles/index.css'
-import '@styles/icons.css'
-import Meta from '@components/Head/Meta.astro'
-import ThemeInit from '@components/Head/ThemeInit.astro'
-import MarketingHeader from '@components/Marketing/Header.astro'
-import MarketingFooter from '@components/Marketing/Footer.astro'
----
-
-
-
-
-
-
-
-
-
- Skip to main content
-
-
-
-
-
-
-
-```
-
-The important part is what is missing, not what is added: no bootstrap script, no view transitions router, no shell web components, no PWA plumbing, no analytics, no footer bug reporter, no search, no theme-picker island.
-
-For remeasuring shell cost, I would do it in two passes:
-
-1. Build the homepage against that stripped marketing layout and rerun the same index.html script extraction you already used. That gives you "current homepage content + lighter shell".
-
-2. Build one temporary blank marketing page with the same layout and almost no body content. That gives you the shell baseline by itself.
-
-That second number is the one you actually want if the question is "what does the homepage pay just for the shell?" Right now your current measurement is a mix of shell cost and page-local cost. A lighter layout lets you separate them cleanly.
-
-The existing repo precedent for this is PrintLayout.astro:1. The structural lesson from that file is correct: a truly light layout is a separate layout, not a flag on BaseLayout.astro:1. Once you start making BaseLayout conditional, you usually keep most of the bundle edges anyway.
-
-If you want, I can next turn this into a concrete change proposal with:
-
-1. the exact components I would remove from the homepage shell first,
-2. the likely script entries that would disappear,
-3. the order I would test them in so the deltas stay attributable.
-
-I'm narrowing this to the behavioral decision: which shell features should be absent on the homepage versus deferred behind explicit triggers, so the answer stays operational instead of theoretical.Not by default. The point of a lighter homepage layout is that most of those features should simply be absent on /, not secretly reintroduced through another global loader.
-
-The right split is:
-
-- Features that can just not exist on the homepage: BaseLayout.astro:134, footer bug reporter, header search, tooltip shell, and the full bootstrap from index.astro:64. If they are not rendered, nothing is broken; the homepage just behaves like a normal static marketing page.
-
-- Features that can be deferred only if the homepage still needs them: analytics, consent UI, theme picker, and maybe service worker registration. Those should be loaded by explicit need, not by a generic site bootstrap.
-
-A good rule is: if a user cannot discover the missing behavior on the homepage, do not lazy load it there. Just omit it.
-
-What that means feature by feature:
-
-- View transitions router: do not lazy load it on the homepage. If BaseLayout.astro:134 is absent, links from home still work as normal full-page navigations. Then once the user lands on an interior page that uses BaseLayout.astro:1, view transitions can resume there. Nothing is broken; you just give up SPA-style transitions on the landing page.
-
-- Bootstrap script: do not replace one big bootstrap with a lazy bootstrap. That just moves the cost later and keeps the coupling. The better move is to stop treating index.astro:64 as mandatory for every page, and only load the specific feature modules that a given page actually needs.
-
-- Search: unless homepage search is a product requirement, it should not work there because it should not exist there. If you want a search affordance on home, that is a good candidate for interaction-triggered loading.
-
-- Theme picker: separate "theme is applied" from "theme picker UI exists." The tiny theme-init path can still run so the page honors stored theme without FOUC, but the picker island itself can be absent on home. If you keep a theme control on home, then load that UI on click.
-
-- Consent banner: this is the one I would not hand-wave away. If your policy requires consent controls on first entry, keep the minimal consent affordance on the homepage. But even here, I would aim for a narrow consent-specific loader, not the whole shell.
-
-- Analytics: if you need marketing attribution on the homepage, defer it independently after consent or after initial render. If you are measuring shell cost, it should not be bundled through the shared page shell.
-
-- PWA plumbing: for a marketing homepage, I would usually omit service worker registration and offline prefetch on /. Those features can begin on later app-like pages if they still matter.
-
-So the short answer is: some things should intentionally not work on the homepage because they are not part of the homepage experience. Only lazy load the subset that is both valuable on / and user- or policy-driven.
-
-The mistake to avoid is "minimal layout plus hidden global rehydration." If you do that, you keep the same architecture and mostly just delay the pain.
-
-If you want, I can map these into three buckets next:
-
-1. omit entirely on /
-2. keep but defer on /
-3. keep synchronous on / because they are required for correctness or compliance
-
- I'm going to turn the shell features into three decision buckets, then separate "hidden global rehydration" from legitimate targeted deferral so the tradeoff is explicit.This is the mapping I would use for the homepage.
-
-#### Bucket 1
-
-Omit entirely on the homepage.
-
-- View transitions router. If BaseLayout.astro:134 is not used on the homepage, links still work through normal navigations. You lose transition polish on entry, but not functionality.
-
-- Global bootstrap. The large boot path from index.astro:64 should not exist on a marketing landing page unless a concrete homepage feature needs it.
-
-- Header search. If search is not a primary homepage action, do not ship it there through Header.
-
-- Theme picker UI. Keep theme application if needed, but omit the picker island itself from BaseLayout.astro:147.
-
-- Footer bug reporter and footer enhancement logic from Footer.
-
-- Tooltip shell. That is app chrome, not marketing-page core.
-- PWA offline prefetch. The homepage does not need to pay for PrefetchOfflinePage.
-
-#### Bucket 2
-
-Keep only if needed, and defer behind an explicit trigger or policy boundary.
-
-- Analytics. If you need attribution on first entry, load analytics independently after consent or after first paint. Do not inherit it from the shared shell in BaseLayout.astro:190.
-
-- Consent UI. This is the strongest candidate for "keep, but narrow." If the site needs consent handling on first visit, load only the consent path from Consent/Banner, not the whole app shell.
-
-- Theme picker, but only if you expose a theme control on the homepage. If there is no control, do not load the picker.
-
-- Search, but only if you make search a visible homepage action. Then load it on click or open, not on page load.
-Service worker registration, but only if there is a real homepage product reason. Usually I would start with "off on home" and justify it back in later.
-
-#### Bucket 3
-
-Keep synchronously because it is required for correctness, presentation, or compliance.
-
-- Critical metadata and head tags from src/components/Head/index.astro, but split away from the bootstrap entry.
-Theme initialization only, if you need to avoid flash-of-wrong-theme. That is different from keeping the theme picker.
-
-- Basic skip link and static header/footer structure.
-
-- Any legally required consent placeholder if policy requires that users can immediately see or access privacy controls on first entry.
-
-#### "Minimal layout plus hidden global rehydration" is not a synonym for deferred loading.
-
-They look similar from far away because both reduce some upfront execution, but architecturally they are different.
-
-**Deferred loading, used well, means:**
-
-- the homepage starts with a genuinely smaller feature surface
-
-- only a specific feature bundle is loaded later
-
-- the trigger is explicit, like consent accepted, user clicked search, user opened theme control, or a clearly bounded idle task
-
-- the deferred bundle is local to that feature
-
-**Hidden global rehydration means:**
-
-- you render a minimal-looking layout on the server
-
-- but you still attach a general-purpose client bootstrap later
-
-- that bootstrap scans the page, wires up many shell behaviors, and starts recreating the same global runtime you thought you removed
-
-- the coupling is still global, so the homepage remains architecturally tied to the app shell even if the work happens later
-
-So the difference is not timing alone. It is scope and coupling.
-
-A good smell test is this:
-
-- If the later-loaded code says "initialize homepage search," that is targeted deferral.
-
-- If the later-loaded code says "initialize the site" or "bootstrap all shell behaviors," that is hidden global rehydration.
-
-**Why hidden global rehydration is usually the wrong move here:**
-
-- It often preserves most of the total JS cost, just shifted later.
-
-- It keeps shared dependencies sticky, so the homepage still drags the app shell architecture behind it.
-
-- It can improve some Lighthouse timings while still hurting real-user experience on slower devices once idle work kicks in.
-
-- It makes attribution harder, because the homepage no longer has a clean boundary between what belongs to marketing and what belongs to the application shell.
-
-**Why targeted deferral is still useful:**
-
-- It lets the homepage stay mostly static.
-
-- It preserves specific high-value behaviors.
-
-- It makes the bundle graph legible: this feature exists because the user did something that asked for it.
-
-**So my recommendation is not "defer everything." It is:**
-
-- Remove whole homepage-irrelevant shell features.
-
-- Keep only the smallest synchronous primitives needed for correctness.
-
-- Defer a small number of user-discoverable or compliance-driven features with feature-specific loaders.
-
-- Avoid any replacement that effectively recreates index.astro:64 under a different name.
-
-If we evaluate this tomorrow, the practical decision framework is simple:
-
-- If a homepage visitor would never miss it, omit it.
-- If they only need it after an action, defer it specifically.
-- If the page is broken or non-compliant without it, keep it synchronous.
-
-If you want tomorrow, I can turn this into a concrete scorecard for each current homepage shell feature with columns for user value, compliance risk, likely byte impact, and recommended bucket.
-
-#### Chunk weight
-
-Vercel supports both gzip and brotli, and if the client supports Brotli, Vercel prefers Brotli over gzip for eligible text assets like HTML, CSS, and JavaScript.
-
-From the current built homepage, the JS breaks down like this:
-
-Direct entries: about 279.7 KB raw, 100.0 KB gzip
-Shared chunks: about 45.7 KB raw, 17.5 KB gzip
-
-Total JS graph for the homepage: about 325.5 KB raw, 117.4 KB gzip
-
-Homepage CSS only:
-
-Total CSS raw: 220,895 bytes, about 215.7 KB
-Total CSS gzip: 34,691 bytes, about 33.9 KB
-Total CSS Brotli estimate: 28,242 bytes, about 27.6 KB
-
-And for the Brotli side of the JS audit:
-
-Total reachable homepage JS Brotli estimate: 106,493 bytes, about 104.0 KB
-
-If you want the full first-load asset picture from the current build:
-
-JS + CSS raw: 554,167 bytes, about 541.2 KB
-JS + CSS gzip: 154,920 bytes, about 151.3 KB
-JS + CSS Brotli estimate: 134,735 bytes, about 131.6 KB
-
-What that means in practice:
-
-The 22 entry chunks are not a strict A -> B -> C -> D chain. They should mostly look like a stack.
-But there is still a second-wave dependency step for shared imports, so it is not a perfectly flat stack either.
-In the current build, the shared JS layer is only 3 chunks deep and the max dependency depth is 1, so this is a one-step staircase, not a long waterfall.
-The three shared chunks currently causing that extra step are:
-
-- embla-carousel.esm
-- focus-trap.esm
-- workbox-window.prod.es5
-
-So the honest answer is:
-
-- No, you do not appear to have a severe multi-hop dependency waterfall.
-- Yes, you do have some waterfall behavior because shared chunks are not preloaded and must be discovered from the entry modules.
-
-The bigger problem on this homepage is still total shipped JS and chunk fan-out, not a deep chained graph. If you opened DevTools, I would expect "mostly stacked direct requests, plus a smaller second wave" rather than a long staircase.
-
-If you want, I can next turn that into a plain-English takeaway for your _TODO.md, like: "fan-out is the main issue; dependency waterfall is present but shallow."
diff --git a/astro.config.ts b/astro.config.ts
index 383c5dd1..ba366f66 100644
--- a/astro.config.ts
+++ b/astro.config.ts
@@ -105,6 +105,8 @@ const standardIntegrations = [
sitemap({
serialize: createSerializeFunction({
exclude: [
+ /** Legacy /deep-dive/:slug URLs are 301 redirect stub pages, not content */
+ 'deep-dive',
'downloads',
'offline',
'print',
@@ -155,6 +157,8 @@ export default defineConfig({
},
redirects: {
'/tags': '/articles',
+ /** Canonical sitemap URL tools expect; the integration emits sitemap-index.xml */
+ '/sitemap.xml': { status: 301, destination: '/sitemap-index.xml' },
},
/** Change URL between development and production environments */
site: getSiteUrl(),
diff --git a/public/downloads/kubernetes-pod-disruption-budget-autoscaler-node-rotation.pdf b/public/downloads/kubernetes-pod-disruption-budget-autoscaler-node-rotation.pdf
new file mode 100644
index 00000000..e213c7e7
Binary files /dev/null and b/public/downloads/kubernetes-pod-disruption-budget-autoscaler-node-rotation.pdf differ
diff --git a/public/downloads/postgresql-connection-pooling-saturation-sizing.pdf b/public/downloads/postgresql-connection-pooling-saturation-sizing.pdf
index 568dfe84..680f6eba 100644
Binary files a/public/downloads/postgresql-connection-pooling-saturation-sizing.pdf and b/public/downloads/postgresql-connection-pooling-saturation-sizing.pdf differ
diff --git a/public/pdf/resume.pdf b/public/pdf/resume.pdf
index 46b827ef..6f25fd6d 100644
Binary files a/public/pdf/resume.pdf and b/public/pdf/resume.pdf differ
diff --git a/src/components/CallToAction/Download/Print.astro b/src/components/CallToAction/Download/Print.astro
new file mode 100644
index 00000000..dcf91d4d
--- /dev/null
+++ b/src/components/CallToAction/Download/Print.astro
@@ -0,0 +1,13 @@
+---
+/**
+ * Print-safe Download CTA variant.
+ *
+ * The interactive Download CTA is a web component (gated download funnel)
+ * that is hidden from print output (`print:hidden!`). A "download the PDF"
+ * banner is meaningless inside the PDF itself, so this variant renders
+ * nothing at all. It exists so the print page's MDX component mapping can
+ * resolve ` ` usages in article bodies without pulling
+ * client-side markup into generated PDFs (same pattern as
+ * FileExplorer/Print.astro).
+ */
+---
diff --git a/src/components/CallToAction/Newsletter/Print.astro b/src/components/CallToAction/Newsletter/Print.astro
new file mode 100644
index 00000000..6714ff62
--- /dev/null
+++ b/src/components/CallToAction/Newsletter/Print.astro
@@ -0,0 +1,12 @@
+---
+/**
+ * Print-safe Newsletter CTA variant.
+ *
+ * The interactive Newsletter CTA is a web component (email capture form) that
+ * is hidden from print output (`print:hidden!`). A signup form is meaningless
+ * in a static PDF, so this variant renders nothing at all. It exists so the
+ * print page's MDX component mapping can resolve ` ` usages in
+ * article bodies without pulling client-side markup into generated PDFs
+ * (same pattern as FileExplorer/Print.astro).
+ */
+---
diff --git a/src/components/Pages/Resume/index.astro b/src/components/Pages/Resume/index.astro
index 11fff0ce..ad6c2a11 100644
--- a/src/components/Pages/Resume/index.astro
+++ b/src/components/Pages/Resume/index.astro
@@ -4,6 +4,7 @@ import type { RenderedClient } from '@components/Pages/Resume/server'
import HeadContent from '@components/Head/index.astro'
import Contact from '@components/Pages/Resume/partials/Contact.astro'
import Controls from '@components/Pages/Resume/partials/Controls.astro'
+import Education from '@components/Pages/Resume/partials/Education.astro'
import Experience from '@components/Pages/Resume/partials/Experience.astro'
import Languages from '@components/Pages/Resume/partials/Languages.astro'
import Persona from '@components/Pages/Resume/partials/Persona.astro'
@@ -76,6 +77,10 @@ const { contactData, pageDescription, pageTitle, path, renderedClients, resume }
+
+
+
+
diff --git a/src/components/Pages/Resume/partials/Education.astro b/src/components/Pages/Resume/partials/Education.astro
new file mode 100644
index 00000000..f1f1b880
--- /dev/null
+++ b/src/components/Pages/Resume/partials/Education.astro
@@ -0,0 +1,80 @@
+---
+import type { CollectionEntry } from 'astro:content'
+import Icon from '@components/Icon/index.astro'
+import List from '@components/List/index.astro'
+
+export interface Props {
+ education: CollectionEntry<'resume'>['data']['education']
+}
+
+const { education } = Astro.props as Props
+
+/** ISO month (YYYY-MM) for , tolerant of free-form input */
+const toIsoMonth = (value?: string): string | undefined => {
+ if (!value) return undefined
+ const parsed = new Date(value)
+ if (Number.isNaN(parsed.getTime())) return undefined
+ return `${parsed.getFullYear()}-${String(parsed.getMonth() + 1).padStart(2, '0')}`
+}
+
+const startIso = toIsoMonth(education.startDate)
+const endIso = toIsoMonth(education.graduationDate)
+const highlightItems = (education.highlights ?? []).map(text => ({ text }))
+---
+
+
+
+ Education
+
+
+ {/** Degree title and dates row (mirrors the Experience role/dates row) */}
+
+
+ {education.degree}
+
+ {
+ (education.startDate || education.graduationDate) && (
+
+ {education.startDate && {education.startDate} }
+ {education.startDate && education.graduationDate && (
+ —
+ )}
+ {education.graduationDate && {education.graduationDate} }
+
+ )
+ }
+
+ {/** School name and location row (mirrors the Experience company/location row) */}
+
+
+
+ {education.school}
+
+
+
+
+
+ {education.campus}
+
+
+
+ {highlightItems.length > 0 &&
}
+
+
diff --git a/src/components/Pages/Resume/partials/Experience.astro b/src/components/Pages/Resume/partials/Experience.astro
index b36bc758..dedfb57d 100644
--- a/src/components/Pages/Resume/partials/Experience.astro
+++ b/src/components/Pages/Resume/partials/Experience.astro
@@ -25,10 +25,7 @@ const { renderedClients } = Astro.props as Props
return (
1 && (
← Previous
@@ -198,7 +198,7 @@ const path = buildTagPagePath(tag, currentPage)
<>
1
@@ -215,7 +215,7 @@ const path = buildTagPagePath(tag, currentPage)
return (
…}
{totalPages}
@@ -242,7 +242,7 @@ const path = buildTagPagePath(tag, currentPage)
{currentPage < totalPages && (
Next →
diff --git a/src/components/Search/SearchResults/client/index.ts b/src/components/Search/SearchResults/client/index.ts
index 69a26235..e730cf28 100644
--- a/src/components/Search/SearchResults/client/index.ts
+++ b/src/components/Search/SearchResults/client/index.ts
@@ -5,6 +5,7 @@ import type { WebComponentModule } from '@components/scripts/@types/webComponent
import {
isForbiddenClientActionError,
normalizeClientActionError,
+ type ClientActionError,
} from '@components/scripts/errors/actionClient'
import { handleScriptError } from '@components/scripts/errors/handler'
import { addScriptBreadcrumb } from '@components/scripts/errors'
@@ -14,6 +15,28 @@ import type { SearchHit } from '@actions/search/@types'
const MIN_QUERY_LENGTH = 2
+/**
+ * The action client surfaces some server failures (e.g. an empty 500 from the
+ * function) as error objects with no message, which Sentry then reports as
+ * "Unknown error". Compose a diagnosable message from the normalized action
+ * error's code/status so production reports stay actionable.
+ */
+const toReportableSearchError = (
+ error: unknown,
+ actionError: ClientActionError | undefined
+): unknown => {
+ if (error instanceof Error && error.message) {
+ return error
+ }
+
+ const details: string[] = []
+ if (actionError?.code) details.push(`code ${actionError.code}`)
+ if (actionError?.status) details.push(`HTTP ${actionError.status}`)
+
+ const suffix = details.length > 0 ? ` (${details.join(', ')})` : ' with no error details'
+ return new Error(`Search action failed${suffix}`, { cause: error })
+}
+
const resultTypeLabels: Record = {
articles: 'Article',
'case-studies': 'Case Study',
@@ -596,7 +619,7 @@ export class SearchResultsElement extends LitElement {
return
}
- handleScriptError(error, context)
+ handleScriptError(toReportableSearchError(error, actionError), context)
this.renderResults([])
this.clearMeta()
this.showError(message)
@@ -633,7 +656,7 @@ export class SearchResultsElement extends LitElement {
return
}
- handleScriptError(error, context)
+ handleScriptError(toReportableSearchError(error, actionError), context)
this.renderResults([])
this.clearMeta()
this.showError(
diff --git a/src/components/Social/Highlighter/Print.astro b/src/components/Social/Highlighter/Print.astro
new file mode 100644
index 00000000..3399c841
--- /dev/null
+++ b/src/components/Social/Highlighter/Print.astro
@@ -0,0 +1,28 @@
+---
+/**
+ * Print-safe Highlighter variant.
+ *
+ * The interactive Highlighter is a web component that shows social sharing
+ * options on hover and is hidden from print output (`print:hidden!`). PDF
+ * rendering cannot execute web components, so this variant preserves only the
+ * highlight styling — the text emphasis survives into generated PDFs without
+ * any client-side behavior.
+ *
+ * Mirrors the Props interface of `index.astro` so the two are interchangeable
+ * in MDX component mappings (same pattern as FileExplorer/Print.astro).
+ */
+
+export interface Props {
+ /** Unused in print output; accepted for prop compatibility with Highlighter */
+ ariaLabel?: string
+ /** Additional CSS classes */
+ class?: string
+}
+
+const { class: className } = Astro.props
+---
+
+
diff --git a/src/components/Toc/index.astro b/src/components/Toc/index.astro
index 7feded4d..7eb70d3a 100644
--- a/src/components/Toc/index.astro
+++ b/src/components/Toc/index.astro
@@ -15,7 +15,7 @@ const tocTree = buildTocTree(items)
{
tocTree.length > 0 && (
<>
-
+
{/* Open button, partial circle on right edge and visible when drawer is closed */}
void) | null
+let observeSpy: ReturnType
+let disconnectSpy: ReturnType
+
+class FakeResizeObserver {
+ constructor(callback: () => void) {
+ roCallback = callback
+ }
+ observe = observeSpy
+ unobserve = vi.fn()
+ disconnect = disconnectSpy
+}
+
+const rect = (top: number, height: number): DOMRect =>
+ ({
+ top,
+ bottom: top + height,
+ height,
+ left: 0,
+ right: 300,
+ width: 300,
+ x: 0,
+ y: top,
+ toJSON: () => ({}),
+ }) as DOMRect
+
+/** Sidebar top tracks the scroll position plus any applied transform. */
+function currentSidebarTop(): number {
+ const match = /translateY\((-?\d+(?:\.\d+)?)px\)/.exec(sidebar.style.transform)
+ return state.docTop - scroller.scrollTop + (match ? Number(match[1]) : 0)
+}
+
+/** Wait long enough for the rAF-debounced update to run. */
+const flushUpdate = (): Promise => new Promise(resolve => setTimeout(resolve, 40))
+
+function setupDom(): void {
+ document.body.innerHTML = `
+
+
+
+ `
+ headerEl = document.querySelector('.header-fixed') as HTMLElement
+ progressEl = document.querySelector('[data-progress-bar]') as HTMLElement
+ scroller = document.getElementById('scroll-viewport') as HTMLElement
+ container = document.getElementById('container') as HTMLElement
+ sidebar = document.getElementById('sidebar') as HTMLElement
+
+ state = { chromeBottom: 104, docTop: NATURAL_TOP, sidebarHeight: SIDEBAR_HEIGHT }
+
+ headerEl.getBoundingClientRect = () => rect(0, state.chromeBottom)
+ progressEl.getBoundingClientRect = () => rect(0, 0)
+ sidebar.getBoundingClientRect = () => rect(currentSidebarTop(), state.sidebarHeight)
+ container.getBoundingClientRect = () => rect(-1000, CONTAINER_BOTTOM + 1000)
+
+ Object.defineProperty(window, 'innerWidth', { configurable: true, value: 1440 })
+ Object.defineProperty(window, 'innerHeight', { configurable: true, value: 900 })
+}
+
+beforeEach(() => {
+ roCallback = null
+ observeSpy = vi.fn()
+ disconnectSpy = vi.fn()
+ vi.stubGlobal('ResizeObserver', FakeResizeObserver)
+ setupDom()
+})
+
+afterEach(() => {
+ vi.unstubAllGlobals()
+ document.body.innerHTML = ''
+})
+
+describe('initStickySidebar chrome tracking', () => {
+ it('pins a short sidebar below the measured chrome on init', () => {
+ initStickySidebar(sidebar, container)
+ // visibleTop = 104 + 16 = 120; naturalTop = 100 -> translateY(20px)
+ expect(sidebar.style.transform).toBe('translateY(20px)')
+ expect(observeSpy).toHaveBeenCalledWith(headerEl)
+ expect(observeSpy).toHaveBeenCalledWith(progressEl)
+ })
+
+ it('re-measures when the header resizes (WAAPI squish path)', async () => {
+ initStickySidebar(sidebar, container)
+ expect(sidebar.style.transform).toBe('translateY(20px)')
+
+ // Header squishes after the scroll stops: chrome bottom 104 -> 60
+ state.chromeBottom = 60
+ roCallback?.()
+ await flushUpdate()
+
+ // visibleTop = 60 + 16 = 76 < naturalTop 100 -> back to natural position
+ expect(sidebar.style.transform).toBe('translateY(0px)')
+ })
+
+ it('re-measures when the header transform transition ends', async () => {
+ initStickySidebar(sidebar, container)
+ state.chromeBottom = 60
+ headerEl.dispatchEvent(new Event('transitionend'))
+ await flushUpdate()
+
+ expect(sidebar.style.transform).toBe('translateY(0px)')
+ })
+
+ it('ignores transitionend events bubbling from header children', async () => {
+ initStickySidebar(sidebar, container)
+ const child = document.getElementById('header-child') as HTMLElement
+ state.chromeBottom = 60
+ child.dispatchEvent(new Event('transitionend', { bubbles: true }))
+ await flushUpdate()
+
+ // Target filter rejects the event; stale transform remains
+ expect(sidebar.style.transform).toBe('translateY(20px)')
+ })
+
+ it('cleanup disconnects the observer and removes the transition listener', async () => {
+ const destroy = initStickySidebar(sidebar, container)
+ destroy()
+
+ expect(disconnectSpy).toHaveBeenCalledTimes(1)
+ expect(sidebar.style.transform).toBe('')
+
+ state.chromeBottom = 60
+ headerEl.dispatchEvent(new Event('transitionend'))
+ await flushUpdate()
+ expect(sidebar.style.transform).toBe('')
+ })
+
+ it('still tracks scroll when ResizeObserver is unavailable', async () => {
+ vi.stubGlobal('ResizeObserver', undefined)
+ initStickySidebar(sidebar, container)
+ expect(sidebar.style.transform).toBe('translateY(20px)')
+
+ state.chromeBottom = 60
+ scroller.dispatchEvent(new Event('scroll'))
+ await flushUpdate()
+ expect(sidebar.style.transform).toBe('translateY(0px)')
+ })
+})
+
+describe('initStickySidebar tall sidebar pin tracking', () => {
+ const scrollTo = (top: number): void => {
+ scroller.scrollTop = top
+ scroller.dispatchEvent(new Event('scroll'))
+ }
+
+ it('re-attaches a top pin when the chrome moves without a scroll event', async () => {
+ state.sidebarHeight = TALL_SIDEBAR_HEIGHT
+ state.docTop = 600
+ initStickySidebar(sidebar, container)
+
+ // Scroll down past the sidebar: it drifts, then bottom-pins
+ scrollTo(800)
+ await flushUpdate()
+ expect(sidebar.style.transform).toBe('')
+ scrollTo(1000)
+ await flushUpdate()
+ // bottom pin: 884 - 1200 - (600 - 1000) = 84
+ expect(sidebar.style.transform).toBe('translateY(84px)')
+
+ // Scroll back up: releases from the bottom, then top-pins
+ scrollTo(600)
+ await flushUpdate()
+ expect(sidebar.style.transform).toBe('translateY(84px)')
+ scrollTo(500)
+ await flushUpdate()
+ // top pin: visibleTop 120 - naturalTop (600 - 500) = 20
+ expect(sidebar.style.transform).toBe('translateY(20px)')
+
+ // Header EXPANDS after the scroll stops (chrome bottom 104 -> 140).
+ // The top-pinned sidebar must track to the new pin line without a scroll.
+ state.chromeBottom = 140
+ roCallback?.()
+ await flushUpdate()
+ // visibleTop = 140 + 16 = 156 -> 156 - 100 = 56
+ expect(sidebar.style.transform).toBe('translateY(56px)')
+
+ // Header then squishes below the natural position: clamped to natural
+ state.chromeBottom = 60
+ roCallback?.()
+ await flushUpdate()
+ expect(sidebar.style.transform).toBe('translateY(0px)')
+ })
+})
diff --git a/src/components/scripts/stickySidebar.ts b/src/components/scripts/stickySidebar.ts
index 2721468c..83306741 100644
--- a/src/components/scripts/stickySidebar.ts
+++ b/src/components/scripts/stickySidebar.ts
@@ -67,6 +67,13 @@ export function initStickySidebar(
let prevScrollTop = scrollContainer.scrollTop
let rafId: number | null = null
const scroller = scrollContainer
+ /**
+ * Pin state for tall sidebars. Tracked explicitly so that when the fixed
+ * chrome moves WITHOUT a scroll event (squishy header animation finishing
+ * after the last scroll frame), a pinned sidebar re-attaches to its pin
+ * line instead of settling a few pixels off.
+ */
+ let pinMode: 'none' | 'top' | 'bottom' = 'none'
/**
* Compute and apply the correct translateY for the sidebar based on
@@ -119,16 +126,30 @@ export function initStickySidebar(
// Scrolling DOWN: pin bottom at visibleBottom when it would go above
if (actualBottom < visibleBottom) {
newTranslateY = visibleBottom - sidebarHeight - naturalTop
+ pinMode = 'bottom'
} else {
newTranslateY = currentTranslateY
+ pinMode = 'none'
}
} else if (scrollDelta < 0) {
// Scrolling UP: pin top at visibleTop when it would go below
if (actualTop > visibleTop) {
newTranslateY = visibleTop - naturalTop
+ pinMode = 'top'
} else {
newTranslateY = currentTranslateY
+ pinMode = 'none'
}
+ } else if (pinMode !== 'none') {
+ /**
+ * No scroll movement, but the chrome may have moved (the squishy
+ * header finishes its animation after the last scroll frame).
+ * Re-attach an active pin to its line; a free sidebar stays put.
+ */
+ newTranslateY =
+ pinMode === 'bottom'
+ ? visibleBottom - sidebarHeight - naturalTop
+ : visibleTop - naturalTop
} else {
newTranslateY = currentTranslateY
}
@@ -136,6 +157,7 @@ export function initStickySidebar(
// Clamp: never go above natural position or below container bottom
newTranslateY = Math.max(0, Math.min(newTranslateY, maxTranslateY))
+ if (newTranslateY === 0) pinMode = 'none'
// Apply only when value changes meaningfully (avoid sub-pixel jitter)
if (Math.abs(newTranslateY - currentTranslateY) > 0.5) {
@@ -162,12 +184,51 @@ export function initStickySidebar(
scroller.addEventListener('scroll', onScroll, { passive: true })
window.addEventListener('resize', onScroll, { passive: true })
+ /**
+ * The squishy header finishes its collapse/expand animation AFTER the last
+ * scroll frame: its size changes run via WAAPI (no transitionend fires) and
+ * its slide runs as a CSS translateY transition (no resize events fire).
+ * Without re-measuring when the animation completes, the sidebar settles a
+ * few pixels off its pin target until the next scroll or resize. Track both
+ * signals — ResizeObserver for size changes (fires per frame during WAAPI)
+ * and transitionend for the transform — and re-run the update.
+ */
+ const headerEl = getHeaderFixedElement()
+ const progressEl = getProgressBarElement()
+
+ let resizeObserver: ResizeObserver | null = null
+ const ResizeObserverCtor = typeof ResizeObserver === 'function' ? ResizeObserver : undefined
+
+ if (ResizeObserverCtor) {
+ try {
+ resizeObserver = new ResizeObserverCtor(() => onScroll())
+ if (headerEl) resizeObserver.observe(headerEl)
+ if (progressEl) resizeObserver.observe(progressEl)
+ } catch (error) {
+ /** ResizeObserver construction can fail in constrained contexts; degrade to scroll-only tracking */
+ resizeObserver = null
+ handleScriptError(error, {
+ scriptName: 'stickySidebar',
+ operation: 'observeChrome',
+ })
+ }
+ }
+
+ /** Transform transitions never resize the header box, so listen for their end directly */
+ const onChromeTransitionEnd = (event: TransitionEvent): void => {
+ if (event.target !== headerEl) return
+ onScroll()
+ }
+ headerEl?.addEventListener('transitionend', onChromeTransitionEnd)
+
// Initial positioning
update()
return () => {
scroller.removeEventListener('scroll', onScroll)
window.removeEventListener('resize', onScroll)
+ resizeObserver?.disconnect()
+ headerEl?.removeEventListener('transitionend', onChromeTransitionEnd)
if (rafId !== null) cancelAnimationFrame(rafId)
sidebar.style.transform = ''
currentTranslateY = 0
diff --git a/src/content.config.ts b/src/content.config.ts
index 9e8fa297..0f5ad01d 100644
--- a/src/content.config.ts
+++ b/src/content.config.ts
@@ -262,7 +262,12 @@ const resumeDataCollection = defineCollection({
degree: z.string(),
campus: z.string(),
geolocationLink: z.url(),
- graduationDate: z.string(),
+ /** Free-form end of attendance, e.g. "May 1996" or "1996" */
+ graduationDate: z.string().optional(),
+ /** Free-form start of attendance, e.g. "1992" — rendered as a range with graduationDate */
+ startDate: z.string().optional(),
+ /** Bullet points rendered under the degree line */
+ highlights: z.array(z.string()).optional(),
}),
email: z.email(),
firstName: z.string(),
diff --git a/src/content/articles/alert-fatigue-reduction-triage-actionable-alerts/index.mdx b/src/content/articles/alert-fatigue-reduction-triage-actionable-alerts/index.mdx
index 0ffe3d68..3ccb8b91 100644
--- a/src/content/articles/alert-fatigue-reduction-triage-actionable-alerts/index.mdx
+++ b/src/content/articles/alert-fatigue-reduction-triage-actionable-alerts/index.mdx
@@ -40,6 +40,8 @@ This article walks through the process I used to take that 200-alert-per-week sy
Alert fatigue has measurable costs, even if teams don't track them. The most direct metric is MTTA — mean time to acknowledge. When engineers are conditioned to expect noise, acknowledgment slows. I've seen MTTA drift from under two minutes to over fifteen as alert volume increased, because the on-call engineer stopped keeping their phone nearby.
+
+
Beyond response time, there's the burnout factor. On-call rotations with high alert volume have higher turnover. Engineers start trading shifts, calling in sick, or quietly job-hunting. The institutional knowledge that walks out the door is expensive to replace.
The metrics worth tracking:
@@ -809,6 +811,26 @@ This doesn't happen overnight. It takes months of consistent effort: auditing al
Alert fatigue is a solvable problem. The solution isn't more sophisticated technology or better machine learning — it's disciplined engineering practice applied to alerting.
+
+
Start with an audit. Know what you have, how often it fires, and what happens when it does. Delete the alerts that never result in action. Combine the alerts that fire together. Tune thresholds to match your SLOs instead of arbitrary numbers.
Make every remaining alert actionable. Write runbooks. Include context in the alert itself. Ensure someone owns each alert and maintains it over time.
diff --git a/src/content/articles/api-deprecation-sunset-headers-consumer-migration/index.mdx b/src/content/articles/api-deprecation-sunset-headers-consumer-migration/index.mdx
index 03931226..22b7895d 100644
--- a/src/content/articles/api-deprecation-sunset-headers-consumer-migration/index.mdx
+++ b/src/content/articles/api-deprecation-sunset-headers-consumer-migration/index.mdx
@@ -64,6 +64,8 @@ The phases are: announcement, active deprecation, sunset warning, read-only mode
The read-only phase is optional but valuable. Disabling write operations while keeping reads working gives consumers a preview of what full removal will feel like. It also reduces the blast radius if someone has a critical read path they forgot about — they will discover it before complete removal, and you will get a support ticket instead of a production outage.
+
+
### Setting Realistic Timelines
The biggest mistake in deprecation planning is setting timelines based on how long you _want_ the deprecation to take rather than how long it _will_ take. Your consumers have their own release cycles, their own priorities, and their own backlogs. Your deprecation is not at the top of their list.
@@ -1191,6 +1193,26 @@ Document these lessons and apply them to the next deprecation. The goal is conti
Remember that API version I inherited, the one marked "deprecated" for two years while still handling 30% of production traffic? We eventually got it to zero. It took executive escalation for one customer, dedicated migration pairing sessions for another, and contract renegotiation for the third. The sunset date slipped by four months. But we removed it — actually removed it, not just declared it deprecated and hoped for the best.
+
+
API deprecation is a coordination problem disguised as a technical problem. The technical implementation — Sunset headers, 410 responses, rate limiting — is straightforward. The hard part is getting hundreds of consumers, each with their own priorities and constraints, to take action on your timeline.
The principles that make deprecation work:
diff --git a/src/content/articles/api-gateway-metrics-traces-logs-debugging/index.mdx b/src/content/articles/api-gateway-metrics-traces-logs-debugging/index.mdx
index 35979889..044f1208 100644
--- a/src/content/articles/api-gateway-metrics-traces-logs-debugging/index.mdx
+++ b/src/content/articles/api-gateway-metrics-traces-logs-debugging/index.mdx
@@ -45,6 +45,8 @@ This article covers how to instrument API gateways so the data actually helps du
Gateway observability is not the same as service observability, and treating it the same way is how you end up with dashboards that look busy but do not help.
+
+
The fundamental difference is scope: every request passes through the gateway. When a backend service has a problem, its own metrics show the issue clearly — error rate spikes, latency increases, throughput drops. But the gateway sees that same problem diluted across all the traffic it handles. If one of ten backends is failing, the gateway's aggregate error rate increases by 10%, which might not even trigger an alert.
+
The three pillars work together. Metrics tell you that something is wrong and give you the aggregate picture. Traces show you exactly where time went for specific requests. Logs capture the detail that explains why failures happened. Without all three, you are debugging blind.
Design your observability for the questions you will ask during incidents:
diff --git a/src/content/articles/api-usage-metering-quotas-cost-attribution/index.mdx b/src/content/articles/api-usage-metering-quotas-cost-attribution/index.mdx
index dfb5ca5d..1cb46c80 100644
--- a/src/content/articles/api-usage-metering-quotas-cost-attribution/index.mdx
+++ b/src/content/articles/api-usage-metering-quotas-cost-attribution/index.mdx
@@ -42,6 +42,8 @@ The pattern I've seen repeatedly: teams build APIs focused on functionality, shi
Not every metric needs to be billable, but you need to capture enough dimensions to support future billing models and answer cost questions. The challenge is balancing granularity against cardinality — too many dimensions and your time-series database explodes, too few and you can't attribute costs accurately.
+
+
The metrics that drive API costs:
+
The pattern that works: start with instrumentation, add visibility through dashboards, introduce soft enforcement with warnings, then move to hard enforcement with quotas and billing. Give teams time to adapt at each phase. The organizations that skip steps create resentment and political battles.
The key principles:
diff --git a/src/content/articles/argocd-sync-failures-gitops-debugging-troubleshooting/index.mdx b/src/content/articles/argocd-sync-failures-gitops-debugging-troubleshooting/index.mdx
index 9ac21109..33b32f89 100644
--- a/src/content/articles/argocd-sync-failures-gitops-debugging-troubleshooting/index.mdx
+++ b/src/content/articles/argocd-sync-failures-gitops-debugging-troubleshooting/index.mdx
@@ -223,6 +223,8 @@ Hooks and sync waves can be combined. In the example above, the migration hook h
Now that you understand the mechanics, let's look at the failure modes. I've grouped these into four categories based on where in the sync process they occur. Recognizing which category you're dealing with tells you where to start debugging.
+
+
### Resource Dependency Failures
The most common sync failures happen because a resource references something that doesn't exist yet. Your Deployment needs a Secret, but the Secret hasn't been created. Your Pod uses a ServiceAccount, but the ServiceAccount's RoleBinding is missing.
@@ -1105,6 +1107,26 @@ The `--cascade=false` flag is critical when deleting an application for recovery
ArgoCD sync failures are frustrating because they break the promise of GitOps: you pushed to Git, so it should just work. But that frustration fades once you understand what's actually happening beneath the abstraction.
+
+
The debugging workflow follows a clear pattern. Start with the ArgoCD UI or CLI to understand _what_ failed. Check sync waves and hooks to understand _when_ it failed. Examine Kubernetes state to understand _why_ it failed. And trace the resource dependency graph to understand whether the failure is isolated or will cascade.
Most sync failures fall into a few categories: resource dependency issues (usually ordering problems), hook failures (scripts that time out or crash), health check failures (resources that don't become ready), and drift detection issues (differences between desired and live state that shouldn't exist). Once you recognize the category, the fix is usually straightforward.
diff --git a/src/content/articles/availability-targets-five-nines-cost-benefit-analysis/index.mdx b/src/content/articles/availability-targets-five-nines-cost-benefit-analysis/index.mdx
index 7e8244e7..474223e6 100644
--- a/src/content/articles/availability-targets-five-nines-cost-benefit-analysis/index.mdx
+++ b/src/content/articles/availability-targets-five-nines-cost-benefit-analysis/index.mdx
@@ -171,6 +171,8 @@ The "10x per nine" rule of thumb is surprisingly accurate when you look at real
Here's what each tier actually looks like in practice. At **99%**, you're running single-region, single-zone infrastructure with a single database instance, manual failover, and basic uptime checks — roughly $500/month in infrastructure. At **99.9%**, you need multi-AZ deployment within a region, database replication with automated failover, and comprehensive APM — around $2,000/month.
+
+
The jump to **99.99%** is where things change fundamentally. You're no longer just adding redundancy within a region; you need multi-region deployment with global replication, automated cross-region failover, and a full observability stack. Infrastructure alone runs $15,000/month or more. And **99.999%** requires active-active global databases, instant automated failover, and predictive monitoring with continuous chaos engineering—$100,000+/month before you've hired anyone.
Notice the jump from three nines to four nines — you go from regional redundancy to global redundancy. That's not just more servers; it's fundamentally different complexity. You're now dealing with cross-region latency, data consistency across continents, and failure modes that don't exist in single-region deployments.
@@ -783,6 +785,26 @@ The most important part is the rationale. When someone asks "why aren't we five
The next time someone says "we need five nines," you now have the tools to have a real conversation instead of nodding along.
+
+
Start with the math: each additional nine costs roughly 10x more than the previous one. Then calculate your composite availability — your system can't exceed its weakest critical dependency, and most payment processors, identity providers, and cloud services sit around 99.9-99.95%. Factor in the hidden costs: the 24/7 on-call rotation, the senior SREs, the observability tooling, the opportunity cost of features not built.
Then ask the hard question: what's the actual business value of that additional availability? For most SaaS products, the answer is less than the cost. The crossover point where four nines pays off is higher than most teams realize — often requiring $19,000 per hour of revenue at risk before the math works.
diff --git a/src/content/articles/backpressure-load-shedding-admission-control-overload/index.mdx b/src/content/articles/backpressure-load-shedding-admission-control-overload/index.mdx
index d2c121f1..790a584e 100644
--- a/src/content/articles/backpressure-load-shedding-admission-control-overload/index.mdx
+++ b/src/content/articles/backpressure-load-shedding-admission-control-overload/index.mdx
@@ -52,6 +52,8 @@ Overload doesn't just make systems slow — it makes them _worse_. There's a vic
The diagram shows three paths to failure, and they often happen simultaneously. The retry loop is the most insidious — well-intentioned client retry logic turns a temporary overload into a sustained assault. Thread pool saturation means requests that _could_ be processed sit waiting for a worker. And memory pressure from growing queues triggers garbage collection pauses that reduce your effective capacity right when you need it most.
+
+
This is why overload tends to get worse rather than self-correcting. Every mechanism designed for reliability under normal conditions — retries, timeouts, connection pooling — becomes a liability under overload.
@@ -2069,6 +2071,26 @@ During an incident, read the dashboard top to bottom: "We're at 120% capacity (L
Every system has limits. The question isn't whether yours will face overload — it's whether it will handle overload gracefully or collapse catastrophically. The patterns in this article share a common philosophy: _admit your limits, communicate them clearly, and degrade predictably_.
+
+
Here's what that looks like in practice:
+
This theory has been tested extensively in aviation, healthcare, and nuclear power. It doesn't hold up. "Careless" people aren't a distinct population you can screen out — everyone makes errors under the right conditions. Fatigue, time pressure, confusing interfaces, incomplete information, and conflicting priorities create errors in even the most skilled practitioners.
> If you believe the problem is a "bad apple," you'll try to remove the bad apple. If you believe the problem is the barrel, you'll redesign the barrel. The evidence from every high-reliability industry is clear: it's always the barrel.
@@ -1141,6 +1143,26 @@ The reviews themselves should be blameless. If metrics are trending poorly, the
Every incident investigation faces a choice: stop at human error, or continue to systemic causes. The first path is easier. It provides closure. It satisfies the instinct to hold someone accountable. But it produces hiding, not learning. The same incidents recur, just with different people taking the blame.
+
+
The harder path — blameless investigation — requires discipline. It means asking "what about the system?" when you've already found a person who made a mistake. It means building culture where people report errors instead of concealing them. It means tracking action items to completion instead of declaring victory when the postmortem document is done.
Let's recap the core principles:
diff --git a/src/content/articles/blue-green-canary-deployment-strategy-comparison/index.mdx b/src/content/articles/blue-green-canary-deployment-strategy-comparison/index.mdx
index 5255daca..4426060b 100644
--- a/src/content/articles/blue-green-canary-deployment-strategy-comparison/index.mdx
+++ b/src/content/articles/blue-green-canary-deployment-strategy-comparison/index.mdx
@@ -120,6 +120,8 @@ That last question is the one teams most often ignore. If your schema migration
Blue/green deployment maintains two identical production environments. At any time, one is "live" (receiving traffic) and one is "idle" (ready for the next deployment). When you deploy, you update the idle environment, validate it, then switch traffic. The old live environment becomes the new idle — ready for instant rollback if needed.
+
+
The mental model is simple: you're always one configuration change away from either the new version or the old one.
+
Blue/green excels when you need instant rollback and can afford the infrastructure cost. It's simpler to understand, simpler to operate, and works with basic load balancing that every team already has. If your staging environment accurately mirrors production and your database changes are backward-compatible, blue/green gives you everything you need without the complexity of traffic splitting.
Canary excels when you need production validation before full commitment. It catches bugs that only manifest under real traffic patterns, limits blast radius for risky changes, and costs less in infrastructure than running two full environments. But it requires L7 traffic management, version-aware observability, and operational maturity to interpret the analysis results.
diff --git a/src/content/articles/cdn-edge-caching-cache-keys-vary-headers/index.mdx b/src/content/articles/cdn-edge-caching-cache-keys-vary-headers/index.mdx
index d56ce3dc..b49c6aa8 100644
--- a/src/content/articles/cdn-edge-caching-cache-keys-vary-headers/index.mdx
+++ b/src/content/articles/cdn-edge-caching-cache-keys-vary-headers/index.mdx
@@ -127,6 +127,8 @@ Notice the first example uses both `max-age` and `s-maxage` in its `Cache-Contro
A cache key is the identifier the CDN uses to store and retrieve cached responses. When a request arrives, the edge generates a cache key from the request attributes and looks for a matching entry. If found, it returns the cached response. If not, it fetches from origin and stores the response under that key.
+
+
By default, most CDNs use a simplified version of the URL as the cache key:
@@ -1383,6 +1385,26 @@ Code: Comprehensive CDN configuration checklist.
Edge caching is a correctness problem disguised as a performance optimization. The techniques in this article all serve one goal: ensuring the right content reaches the right user while maximizing cache efficiency.
+
+
The key principles to remember:
+
Real chaos engineering follows the scientific method. You form a hypothesis about how your system should behave under specific failure conditions, design a controlled experiment to test that hypothesis, and observe whether reality matches your expectations. The goal isn't to cause outages; it's to build confidence that your system handles failures gracefully — or to learn exactly how it doesn't.
+
The principles are simple: form a hypothesis before you break anything, define abort conditions so experiments don't become incidents, control the blast radius so you're learning rather than causing outages, and follow through on findings by actually fixing them.
diff --git a/src/content/articles/ci-pipeline-caching-docker-layers-dependency-cache/index.mdx b/src/content/articles/ci-pipeline-caching-docker-layers-dependency-cache/index.mdx
index 9bdd07f5..4e5cb85e 100644
--- a/src/content/articles/ci-pipeline-caching-docker-layers-dependency-cache/index.mdx
+++ b/src/content/articles/ci-pipeline-caching-docker-layers-dependency-cache/index.mdx
@@ -74,6 +74,8 @@ Before optimizing, it helps to know where the time actually goes. Here's a break
The biggest wins come from dependency installation and Docker builds — these are both slow __and__ highly cacheable. A 45-minute build can realistically drop to 12 minutes with proper caching of just these two phases.
+
+
### Cache Keys: The Foundation
Cache keys are the mechanism that determines when to use cached artifacts versus rebuilding from scratch. A cache key is a string that uniquely identifies a cached artifact. When your build requests a cache, the CI system looks for an exact match on the key. If found, you get a cache hit. If not, you get a miss and rebuild.
@@ -1008,6 +1010,26 @@ CI caching is deceptively simple on the surface: save files, restore files, skip
]}
/>
+
+
The patterns in this article can reduce build times by 70-90%. But the improvement isn't automatic. You need to design your cache keys deliberately, monitor hit ratios continuously, and debug misses when they occur. Fast builds that produce correct artifacts are the goal. Get there systematically.
Once you've optimized caching, the next bottlenecks are usually test execution time (addressed through parallelization and test splitting) and monorepo builds (which benefit from affected-file detection and incremental builds). Caching is the foundation — get it right first, then layer on these more advanced optimizations.
diff --git a/src/content/articles/circuit-breaker-retry-budget-cascade-failure-prevention/index.mdx b/src/content/articles/circuit-breaker-retry-budget-cascade-failure-prevention/index.mdx
index 02b4cab5..3b97e52d 100644
--- a/src/content/articles/circuit-breaker-retry-budget-cascade-failure-prevention/index.mdx
+++ b/src/content/articles/circuit-breaker-retry-budget-cascade-failure-prevention/index.mdx
@@ -39,6 +39,8 @@ These aren't nice-to-have resilience patterns. They're survival mechanisms for a
Understanding the mechanics of cascade failures requires tracing the path from initial symptom to system-wide collapse. The pattern is predictable, which means it's also preventable.
+
+
Consider a typical service chain: an API gateway calls a checkout service, which calls an inventory service, which queries a database. Each layer has timeouts and retry logic configured independently. Nobody coordinated these settings because each team was responsible for their own service.
The failure starts at the bottom. A database experiences momentary slowness — maybe a long-running query, maybe garbage collection, maybe a network hiccup. What happens next follows a depressingly predictable sequence:
@@ -939,6 +941,26 @@ For most teams, I recommend starting with application-level circuit breakers for
Circuit breakers and retry budgets address two sides of the same problem. Circuit breakers detect when a downstream service is unhealthy and stop sending it traffic — protecting both the downstream from additional load and your own service from wasting resources on requests that will fail. Retry budgets prevent the amplification effect that turns a minor issue into a catastrophic one by limiting total retries across all requests, not just per-request.
+
+
These mechanisms work together. The retry budget prevents overwhelming a service __before__ the circuit opens. The circuit breaker stops traffic __after__ sustained failures are detected. Neither alone is sufficient: circuit breakers without retry budgets still allow amplification during the detection window, and retry budgets without circuit breakers still send initial requests to a service that's clearly down.
diff --git a/src/content/articles/consumer-driven-contract-testing-pact-internal-apis/index.mdx b/src/content/articles/consumer-driven-contract-testing-pact-internal-apis/index.mdx
index 5e6527ee..ee9eb368 100644
--- a/src/content/articles/consumer-driven-contract-testing-pact-internal-apis/index.mdx
+++ b/src/content/articles/consumer-driven-contract-testing-pact-internal-apis/index.mdx
@@ -125,6 +125,8 @@ Documentation without enforcement is fiction. Schemas describe intent; contract
Traditional API testing puts the provider in charge. The provider defines a schema, publishes documentation, and consumers build against it. If the provider changes the API, consumers find out when their code breaks — often in production.
+
+
Consumer-driven contracts invert this model. The __consumer__ defines what it needs from the provider and encodes those expectations in a contract. The provider then verifies it can satisfy that contract. Both sides test against the same artifact, so compatibility is verified before either side deploys.
+
The key insights:
+
Here's the uncomfortable reality for a typical Node.js application: about 60% of vulnerabilities are in layers developers don't directly control.
+
The patterns that make container scanning successful:
+
The pattern has three phases:
+
+
The core principles: treat migrations as a distinct deployment concern with their own safety gates. Maintain backward compatibility so old and new code can coexist during rolling deployments. Use non-blocking DDL operations wherever possible. Always have a rollback plan, even if that plan is "restore from backup." And test migrations against production-like data volumes before they reach production — a migration that works on 10,000 rows can behave very differently against 10 million.
diff --git a/src/content/articles/dead-letter-queue-design-replay-debugging/index.mdx b/src/content/articles/dead-letter-queue-design-replay-debugging/index.mdx
index bd32cd4a..297d56e5 100644
--- a/src/content/articles/dead-letter-queue-design-replay-debugging/index.mdx
+++ b/src/content/articles/dead-letter-queue-design-replay-debugging/index.mdx
@@ -140,6 +140,8 @@ The difference between a useless DLQ and a useful one is metadata. Capture every
The schema you choose for your DLQ messages determines what you can do with them later. Get it wrong and you're back to "I have no idea what happened." Get it right and debugging becomes straightforward.
+
+
### Design Principles
+
The investment required isn't enormous. Enrich messages at failure time with the context you'll need later — error details, trace IDs, failure classification, attempt history. Store them somewhere queryable so you can filter and search instead of manually inspecting messages one by one. Build simple tooling for inspection and replay, even if it's just a CLI wrapper around your storage queries. Establish a triage process so messages don't sit ignored for weeks.
Most of this work is one-time infrastructure that pays dividends every time something goes wrong. The alternative — deleting 50,000 messages because nobody knows what they are or whether they're safe to replay — is a data loss event dressed up as operational hygiene.
diff --git a/src/content/articles/distributed-tracing-sampling-strategies-head-tail/index.mdx b/src/content/articles/distributed-tracing-sampling-strategies-head-tail/index.mdx
index ad825f9f..73a5f94c 100644
--- a/src/content/articles/distributed-tracing-sampling-strategies-head-tail/index.mdx
+++ b/src/content/articles/distributed-tracing-sampling-strategies-head-tail/index.mdx
@@ -127,6 +127,8 @@ Sampling means you can't always find a specific trace by ID. If a customer repor
Before diving into sampling strategies, it helps to understand where sampling fits in the distributed tracing stack. The stack has two sides: backends that store and visualize traces, and SDKs that generate them.
+
+
### Trace Backends and Collectors
The backend is where your traces live after collection. You have several options depending on whether you want managed services or self-hosted infrastructure.
@@ -1170,6 +1172,26 @@ Build your debugging workflow around the assumption that the specific trace you
The premise of this article is counterintuitive: you get more value from your distributed tracing by capturing less data. At scale, 100% sampling isn't just expensive - it's counterproductive. The cost of storing and querying petabytes of routine traces overwhelms any debugging benefit. The teams that get the most from tracing are the ones that sample strategically.
+
+
Head-based sampling is where most teams should start. It's simple to implement (it's built into every tracing SDK), requires no additional infrastructure, and gives you immediate cost control. Sample 10% of traffic and your tracing bill drops by 90%. The trade-off is that you're making decisions with incomplete information - you can't know at request start whether this trace will be interesting.
Tail-based sampling solves the information problem by waiting until traces complete before deciding what to keep. You can capture every error, every slow request, every trace that violated an SLA. The trade-off is operational complexity: stateful collectors, memory pressure, trace-ID routing, and the inevitable edge cases when collectors restart. Don't adopt tail sampling until you need its capabilities and can absorb its operational cost.
diff --git a/src/content/articles/eol-runtime-upgrade-dependency-hell-migration/index.mdx b/src/content/articles/eol-runtime-upgrade-dependency-hell-migration/index.mdx
index 01bf4b0b..11bafba3 100644
--- a/src/content/articles/eol-runtime-upgrade-dependency-hell-migration/index.mdx
+++ b/src/content/articles/eol-runtime-upgrade-dependency-hell-migration/index.mdx
@@ -37,6 +37,8 @@ In this article I will lay out a practical upgrade path: map the graph, identify
Before touching any code, you need to understand what you are actually upgrading. The answer is never just "my application." It is your application plus every package it depends on, plus every package __those__ packages depend on, recursively, until you hit the bottom of the tree.
+
+
### Direct vs Transitive Dependencies
Most teams dramatically underestimate their dependency surface. They look at their package.json, Gemfile, or .csproj and see maybe twenty direct dependencies. That is the visible part. The iceberg below the waterline is the transitive graph — the dependencies of your dependencies, often numbering in the hundreds.
@@ -764,6 +766,26 @@ Document the conditions that trigger rollback and make sure everyone on the team
EOL runtime upgrades are not version bumps. They are forced audits of your entire dependency graph, and the blockers are rarely in code you own. The transitive dependencies — packages you have never looked at, three or four levels deep in the tree — are where upgrades stall.
+
+
The approach that works: map the dependency graph before you start writing code. Identify and classify blockers by type. Sequence the work so that unblocking changes come first, core library upgrades come second, and the runtime change itself comes last. Test against both old and new runtimes in CI throughout the project. Deploy progressively with automated rollback triggers.
The teams that struggle with EOL upgrades are the ones who wait until the deadline is imminent, then try to do everything at once. The teams that handle them smoothly treat upgrades as continuous maintenance — small, frequent updates rather than multi-year gaps that accumulate compounding breakage.
diff --git a/src/content/articles/ephemeral-preview-environments-cost-control-cleanup/index.mdx b/src/content/articles/ephemeral-preview-environments-cost-control-cleanup/index.mdx
index 12df9158..9dac1830 100644
--- a/src/content/articles/ephemeral-preview-environments-cost-control-cleanup/index.mdx
+++ b/src/content/articles/ephemeral-preview-environments-cost-control-cleanup/index.mdx
@@ -38,6 +38,8 @@ This article covers the lifecycle management, cleanup automation, and cost contr
An ephemeral environment moves through a predictable set of states, and understanding this lifecycle is the foundation for cost control. The critical insight is that cost accumulates in the idle state — after the developer has finished testing but before cleanup runs.
+
+
### Environment Lifecycle States
+
+
The goal isn't to minimize spending — it's to maximize value per dollar. A preview environment that catches a bug before production is worth far more than the compute cost. But an environment for an abandoned PR is pure waste. Automate the distinction, and you'll have preview environments that accelerate development without budget surprises.
diff --git a/src/content/articles/flaky-test-diagnosis-race-conditions-e2e-stabilization/index.mdx b/src/content/articles/flaky-test-diagnosis-race-conditions-e2e-stabilization/index.mdx
index 221817f8..0ce825e8 100644
--- a/src/content/articles/flaky-test-diagnosis-race-conditions-e2e-stabilization/index.mdx
+++ b/src/content/articles/flaky-test-diagnosis-race-conditions-e2e-stabilization/index.mdx
@@ -74,6 +74,8 @@ Race conditions are the dominant cause of flakiness. The test and application ar
The telltale symptoms: the test passes locally but fails in CI, passes when you attach a debugger (which slows things down), or behaves inconsistently across different machines. The underlying issue is almost always the same — the test is asserting before the application has finished doing something.
+
+
Common patterns include clicking a button and immediately checking the result (before the async handler completes), interacting with an element while it's still animating, or making assertions before an API response arrives. The fix is always the same principle: __wait for the specific condition you need, not an arbitrary amount of time__.
### Environment Issues (~25% of Flakes)
@@ -1042,6 +1044,26 @@ Flaky tests are a solvable problem, but only if you approach them systematically
]}
/>
+
+
The deeper lesson is that flaky tests are symptoms. They reveal race conditions in your tests, instability in your application, or inconsistency in your environments. Fixing flakes often uncovers real bugs — an API that's slower under load, a component that renders before its data arrives, a cleanup process that doesn't handle edge cases.
diff --git a/src/content/articles/golden-paths-developer-experience-standardization-autonomy/index.mdx b/src/content/articles/golden-paths-developer-experience-standardization-autonomy/index.mdx
index fa03c292..cabf1238 100644
--- a/src/content/articles/golden-paths-developer-experience-standardization-autonomy/index.mdx
+++ b/src/content/articles/golden-paths-developer-experience-standardization-autonomy/index.mdx
@@ -145,6 +145,8 @@ Golden paths work when the path is genuinely better, not just standardized. If y
A well-designed golden path has five components: an entrypoint, a core journey, extension points, escape hatches, and a support model. Each serves a distinct purpose. But before any of that matters, developers need to find the right path for their situation.
+
+
### Path Discovery
The best golden path is useless if developers don't know it exists or can't figure out which one applies to their situation. Path discovery is the zero-th step that makes everything else possible.
@@ -760,6 +762,26 @@ Platform teams with a product mindset build paths that developers love. Platform
The tension between standardization and autonomy is real, but it's not a zero-sum game. Golden paths resolve this tension by making the right thing easy rather than mandatory. When the path genuinely delivers value — faster setup, automatic upgrades, better support — developers choose it because it helps them, not because they're forced.
+
+
The key principles are straightforward: design escape hatches as first-class features so legitimate edge cases have a home; measure adoption to understand path health but never coerce it; evolve paths based on real usage patterns rather than theoretical requirements; and treat path users as customers to delight rather than subjects to control.
The platform team's job is to make standardization the path of least resistance. When you succeed, shadow infrastructure disappears because there's no reason to build it. Teams adopt standards voluntarily because the alternative is more work. Maintenance burden shifts from every team to the platform team, who can invest deeply in getting it right.
diff --git a/src/content/articles/grafana-dashboard-hygiene-pruning-actionable-metrics/index.mdx b/src/content/articles/grafana-dashboard-hygiene-pruning-actionable-metrics/index.mdx
index b07eee86..7e7a7dbc 100644
--- a/src/content/articles/grafana-dashboard-hygiene-pruning-actionable-metrics/index.mdx
+++ b/src/content/articles/grafana-dashboard-hygiene-pruning-actionable-metrics/index.mdx
@@ -134,6 +134,8 @@ The lifecycle reality: creation is easy (click, clone, copy), maintenance is rar
You can't make evidence-based decisions about what to keep or delete without usage data. The first step in any hygiene program is instrumenting your Grafana instance to track who's looking at what.
+
+
### What to Track
For each dashboard, you need four categories of metrics:
@@ -648,6 +650,26 @@ The biggest obstacle to dashboard hygiene isn't technical — it's cultural. Peo
Dashboard hygiene isn't a one-time project. It's ongoing maintenance, like any other operational practice.
+
+
The core practices:
+
### Using the Helm Diff Plugin
The `helm-diff` plugin is the quickest way to compare Helm's view against cluster reality. It shows what would change on an upgrade, but more importantly for drift detection, it can compare the current release against live state.
@@ -617,6 +619,26 @@ GitOps tools like Flux and ArgoCD provide continuous drift detection and correct
Helm's simplicity at small scale becomes operational complexity at large scale. The chart-and-upgrade model works beautifully for a handful of services, but managing dozens of releases across multiple clusters requires discipline and tooling.
+
+
The key practices that keep Helm manageable:
+
### Choosing the Right Key
There are four common strategies, each with distinct tradeoffs:
@@ -901,6 +903,26 @@ Test idempotency explicitly. Send the same message multiple times and verify the
Idempotent message handling isn't a feature you bolt on later - it's a design discipline that shapes how you structure handlers from the start. The patterns in this article form a coherent approach: understand that at-least-once delivery means duplicates __will__ arrive; design idempotency keys from message content, not queue metadata; choose deduplication storage that matches your consistency requirements; prefer naturally idempotent operations where possible; and use transactional patterns like the outbox and saga state machines when you need atomicity across multiple writes.
+
+
The most common mistake I see is relying on queue-level deduplication. SQS FIFO's five-minute window, Kafka's producer idempotency, Azure Service Bus's session deduplication - these are useful supplements, but they don't eliminate the need for handler-side idempotency. Network partitions, visibility timeouts, consumer crashes, and rebalances all create scenarios where messages get redelivered outside those protection windows.
The implementation cost of idempotency is real but manageable. A Redis-based idempotency store adds a few milliseconds of latency. Database-level deduplication with unique constraints adds complexity to your schema. But the alternative - discovering duplicate payments or duplicate order shipments in production - is far more expensive. Build idempotency in from the start, test it explicitly, and treat "at-least-once" as "probably more than once."
diff --git a/src/content/articles/internal-cli-kubectl-terraform-wrapper-abstraction/index.mdx b/src/content/articles/internal-cli-kubectl-terraform-wrapper-abstraction/index.mdx
index 8af63a9b..b7df94bb 100644
--- a/src/content/articles/internal-cli-kubectl-terraform-wrapper-abstraction/index.mdx
+++ b/src/content/articles/internal-cli-kubectl-terraform-wrapper-abstraction/index.mdx
@@ -128,6 +128,8 @@ The best wrappers are thin. They compose underlying tools rather than reimplemen
If you've decided a wrapper is worth building, the next question is __how__ to build it so it doesn't become a liability. The core principle is transparency: your wrapper should add value without hiding what's happening. Developers should always be able to see the underlying commands, bypass the wrapper when needed, and use their existing tool knowledge.
+
+
### The Transparent Wrapper Pattern
The transparent wrapper pattern treats the underlying tool as the source of truth. The wrapper adds hooks for context injection, guard rails, and logging, but everything it doesn't explicitly handle passes through unchanged. Unknown flags? Pass them through. New subcommands? Pass them through. The wrapper should never be the reason a valid command fails.
@@ -923,6 +925,26 @@ When deprecating a wrapper, provide concrete alternatives for every feature. "Us
Most internal CLI wrappers shouldn't exist. Before building one, exhaust the alternatives: shell aliases, config files, documentation, training.
+
+
But when wrappers __are__ worth building - for genuine complexity hiding, meaningful guard rails, or critical context injection - build them well. Design for transparency: pass through unknown flags, show underlying commands, provide bypass modes. Commit to ongoing maintenance. Monitor adoption and bypass rates to catch problems early. And build with eventual deprecation in mind - the best outcome is retirement because the ecosystem caught up or your team outgrew the need.
diff --git a/src/content/articles/internal-developer-portal-platform-self-service-actions/index.mdx b/src/content/articles/internal-developer-portal-platform-self-service-actions/index.mdx
index 4880eeca..5c80b5f8 100644
--- a/src/content/articles/internal-developer-portal-platform-self-service-actions/index.mdx
+++ b/src/content/articles/internal-developer-portal-platform-self-service-actions/index.mdx
@@ -117,6 +117,8 @@ Most organizations plateau at Level 2 or 3. They build beautiful service catalog
The difference between a good self-service action and a frustrating one often comes down to design decisions that seem minor but compound. A database provisioning action that takes 30 seconds to fill out and 15 minutes to complete feels magical. The same action with a confusing form, unclear outcomes, and no progress visibility feels like a different kind of ticket system.
+
+
### Anatomy of a Good Action
A well-designed action has clear structure: identity (what it is), inputs (what the user provides), workflow (what happens), and outputs (what the user gets back). Here's what that looks like for database provisioning:
@@ -585,6 +587,26 @@ Track both efficiency metrics (time saved) and adoption metrics (who's using it)
The distinction between portal and platform comes down to one question: what happens after a developer finds what they need? In a portal, they read documentation and file tickets. In a platform, they click a button and get resources.
+
+
That developer waiting three days for a database? In a real platform, they'd have filled out a form, seen a preview of what would be created, clicked submit, and had credentials in their hands within 15 minutes. No tickets. No meetings. No waiting for another team's availability.
Getting there requires intentional design. Actions need obvious outcomes and sensible defaults. Approval policies should auto-approve aggressively and only route exceptions to humans. Workflow orchestration must handle failures gracefully and clean up after itself. Integrations should abstract away provider specifics so you can evolve the underlying systems without rewriting everything.
diff --git a/src/content/articles/internal-platform-api-versioning-deprecation-breaking-changes/index.mdx b/src/content/articles/internal-platform-api-versioning-deprecation-breaking-changes/index.mdx
index 175245c4..081e27d6 100644
--- a/src/content/articles/internal-platform-api-versioning-deprecation-breaking-changes/index.mdx
+++ b/src/content/articles/internal-platform-api-versioning-deprecation-breaking-changes/index.mdx
@@ -160,6 +160,8 @@ The key question for any change: "Will existing client code break?" If yes, it's
The hardest part of API versioning isn't the mechanics - it's deciding whether a change is breaking in the first place. The core question is simple: _will existing client code still work?_ If no, it's breaking. If yes, you need to dig deeper: is the behavior meaningfully different? Will consumers notice or care?
+
+
Some changes are obviously breaking. Removing an endpoint returns 404 to anyone still calling it. Changing an HTTP method from POST to PUT breaks existing clients with 405 errors. Renaming a URL path parameter from `{id}` to `{serviceId}` makes existing URLs invalid. Adding a required field to requests means all existing requests fail validation. Changing a field's type from string to number breaks deserialization. These require full deprecation process, no exceptions.
Some changes are obviously safe. Adding an optional request field doesn't affect existing requests. Adding a field to responses is fine if clients ignore unknown fields (they should). New endpoints don't touch existing ones. Loosening validation - accepting 200 characters where you previously accepted 100 - doesn't break anything that worked before. Performance improvements and bug fixes are safe unless someone depends on the buggy behavior (it happens).
@@ -773,6 +775,26 @@ Whatever you choose, communicate it in advance. "On August 15, v1 will return 41
Remember that developer who spent a week scrambling because someone shipped a "small cleanup" without versioning? The 40 broken pipelines, the blocked security hotfix, the trust that took months to rebuild? That's what bad API versioning looks like. It's very visible.
+
+
Good API versioning is invisible. Consumers barely notice migrations because they're well-communicated, well-supported, and well-timed. The new version shows up with deprecation warnings months in advance. The migration guide makes the change trivial. By the time sunset arrives, everyone's already moved on.
Getting there requires treating internal APIs with the same discipline as external ones. Internal customers deserve predictable, well-communicated changes - arguably more so, because they can't switch providers when you break them. Use semantic versioning to signal intent clearly. Follow a structured deprecation process with real timelines and escalating communication. Invest in migration support that makes adoption easy: guides, codemods, adapters, validators.
diff --git a/src/content/articles/kubernetes-cluster-upgrade-playbook-risk-reduction/index.mdx b/src/content/articles/kubernetes-cluster-upgrade-playbook-risk-reduction/index.mdx
index f5211de6..d4b837c7 100644
--- a/src/content/articles/kubernetes-cluster-upgrade-playbook-risk-reduction/index.mdx
+++ b/src/content/articles/kubernetes-cluster-upgrade-playbook-risk-reduction/index.mdx
@@ -124,6 +124,8 @@ __Compatibility verification__ catches the issues that break workloads. Check th
Deprecated APIs are the most common source of upgrade failures. An API that works today returns 404 after upgrade, breaking deployments, controllers, and CI pipelines. Catching these before the upgrade is essential.
+
+
The API server tracks which deprecated APIs are being actively used. Query the metrics endpoint to see what's at risk:
```bash
@@ -830,6 +832,26 @@ The goal is confidence. If your validation suite passes, you should feel comfort
Kubernetes upgrades don't have to be scary. The teams I've seen handle them best share a few common practices: they prepare thoroughly with deprecated API detection and compatibility checks; they follow strict upgrade ordering; they use canary clusters and staged rollouts to limit blast radius; they have tested rollback procedures ready __before__ they start; and they validate comprehensively after each phase.
+
+
The investment in upgrade infrastructure pays for itself quickly. Automated API scanning, blue-green node pools, practiced rollbacks, and comprehensive validation all reduce risk and build confidence.
diff --git a/src/content/articles/kubernetes-cost-optimization-resource-sizing-spot-instances/index.mdx b/src/content/articles/kubernetes-cost-optimization-resource-sizing-spot-instances/index.mdx
index d5d043fb..6d7df694 100644
--- a/src/content/articles/kubernetes-cost-optimization-resource-sizing-spot-instances/index.mdx
+++ b/src/content/articles/kubernetes-cost-optimization-resource-sizing-spot-instances/index.mdx
@@ -40,6 +40,8 @@ __Requests__ are what the scheduler uses for placement decisions. When you set `
__Limits__ are enforcement boundaries. CPU limits throttle - if your container tries to use more than its limit, it gets slowed down but keeps running. Memory limits kill - exceed your memory limit and the kernel OOM-kills your container. Limits can exceed what's actually available on a node (overcommit), but requests cannot.
+
+
This distinction matters for cost because __requests determine how many nodes you need__. If every pod requests 1 CPU but only uses 0.1, you're paying for 10x the capacity you need. The scheduler sees the cluster as full when it's actually 90% idle.
Kubernetes assigns a QoS class based on how you set resources. __Guaranteed__ pods (requests equal limits for all resources) get the highest priority and are evicted last under pressure. __Burstable__ pods (requests less than limits) are the common case. __BestEffort__ pods (no requests or limits) are evicted first - avoid these in production. To control your pod's QoS class, set both CPU and memory requests equal to their limits for Guaranteed, or set requests lower than limits (or omit limits) for Burstable.
@@ -454,6 +456,26 @@ With this configuration, the autoscaler first tries to scale spot pools. If spot
Kubernetes cost optimization comes down to a few straightforward practices: measure what you're actually using, right-size resources to match that usage, run stateless workloads on spot instances, and tune the autoscaler to prefer cheaper capacity.
+
+
The biggest wins come from fixing over-provisioned resources - the boring work of adjusting requests to match actual usage. Teams that invest in cost visibility and right-sizing typically reduce spend by 40-60% without any architectural changes. No new services, no migration projects, just better numbers in existing deployment manifests.
diff --git a/src/content/articles/kubernetes-decision-framework-when-not-to-use/index.mdx b/src/content/articles/kubernetes-decision-framework-when-not-to-use/index.mdx
index e9dbfc1c..5a74617c 100644
--- a/src/content/articles/kubernetes-decision-framework-when-not-to-use/index.mdx
+++ b/src/content/articles/kubernetes-decision-framework-when-not-to-use/index.mdx
@@ -40,6 +40,8 @@ Kubernetes solves specific problems: multi-service orchestration, complex networ
When you adopt Kubernetes, you're not just getting container orchestration. You're signing up for an entire ecosystem of components, each with its own failure modes and operational requirements.
+
+
Even with managed Kubernetes (EKS, GKE, AKS), you still need to understand the control plane: how the API server handles requests, how etcd stores state, how the scheduler places pods. "Managed" means the cloud provider handles upgrades and availability, not that you can ignore how it works. When something breaks, you need to understand the system to debug it.
Then there's node infrastructure. You're responsible for node provisioning and lifecycle, container runtime configuration, kubelet settings, and CNI plugin behavior. Networking alone includes pod networking (overlay or native), service networking (ClusterIP, NodePort, LoadBalancer), ingress controllers, network policies, CoreDNS, and often a service mesh. Storage adds storage classes, CSI drivers, PersistentVolume management, and backup strategies.
@@ -524,6 +526,26 @@ Some factors are critical blockers regardless of score: team size under 5 with n
Kubernetes is a powerful platform for the right use cases: many services that need independent deployment, complex networking requirements, multi-team deployments with isolation needs, and horizontal scaling that benefits from orchestration. When you have these problems, Kubernetes' complexity is justified by the problems it solves.
+
+
But it comes with significant operational burden that isn't justified for every workload. The complexity tax is real: ongoing operations, cluster upgrades, debugging distributed systems, and keeping skills current. For small teams, single applications, or workloads that don't need orchestration, simpler infrastructure isn't settling — it's the right engineering decision.
diff --git a/src/content/articles/kubernetes-dns-debugging-ndots-coredns-troubleshooting/index.mdx b/src/content/articles/kubernetes-dns-debugging-ndots-coredns-troubleshooting/index.mdx
index 00bd2d50..e4be2fa3 100644
--- a/src/content/articles/kubernetes-dns-debugging-ndots-coredns-troubleshooting/index.mdx
+++ b/src/content/articles/kubernetes-dns-debugging-ndots-coredns-troubleshooting/index.mdx
@@ -40,6 +40,8 @@ This article covers the DNS internals you need to debug production issues: how `
Understanding the DNS stack is essential for effective debugging. Kubernetes doesn't use a single DNS server. It's a layered system where your pod's `resolv.conf` configuration determines how queries are constructed, CoreDNS handles the actual resolution, and upstream servers resolve anything outside the cluster. Each layer has its own configuration and failure modes.
+
+
### How DNS Resolution Works
The first diagram shows internal service discovery — the fast path. When you look up a service name, the search domain gets appended, CoreDNS finds the service in the Kubernetes API, and you get an IP back immediately.
@@ -664,6 +666,26 @@ Node-local DNS cache is the single most effective optimization for high-traffic
DNS problems in Kubernetes follow predictable patterns once you understand the architecture. The query path — pod's `resolv.conf` → CoreDNS → upstream DNS — gives you three places to look when things break. The `ndots:5` default causes the majority of external DNS latency issues, and the fix is straightforward: reduce ndots in `dnsConfig` or use trailing dots for external FQDNs.
+
+
When debugging, work systematically: verify CoreDNS is running, test from inside a pod (not the node), compare internal versus external resolution, and check whether trailing dots improve latency. Most DNS incidents fall into one of four categories — CoreDNS overload, ndots misconfiguration, upstream DNS issues, or NetworkPolicies blocking port 53—and the debugging workflow identifies which category within minutes.
For production clusters, the optimizations that matter most are negative caching in CoreDNS (to avoid hammering upstream DNS with repeated NXDOMAIN queries), scaling CoreDNS beyond the default two replicas, and deploying node-local DNS cache for high-traffic workloads. These changes are low-risk and high-impact.
diff --git a/src/content/articles/kubernetes-hpa-autoscaling-metrics-tuning-latency/index.mdx b/src/content/articles/kubernetes-hpa-autoscaling-metrics-tuning-latency/index.mdx
index 64631b8d..388a03a1 100644
--- a/src/content/articles/kubernetes-hpa-autoscaling-metrics-tuning-latency/index.mdx
+++ b/src/content/articles/kubernetes-hpa-autoscaling-metrics-tuning-latency/index.mdx
@@ -80,6 +80,8 @@ Code: Basic HPA configuration targeting 50% CPU utilization.
The 50% target might seem conservative, but it's intentional. You want headroom to absorb traffic increases while HPA scales up. A target of 80% means you're already near capacity when HPA decides to act — and by the time new pods are ready, you've been overloaded for minutes.
+
+
### The Delay Problem
The HPA loop sounds fast—15-second intervals — but the total time from "traffic spike begins" to "new capacity receives traffic" is much longer. Every step in the pipeline adds latency.
@@ -978,4 +980,24 @@ HPA tuning comes down to four principles:
The best HPA configuration is one you never think about. Traffic varies, capacity adjusts, users don't notice. Getting there requires measuring actual behavior — not guessing at configurations. Instrument your HPA, watch its scaling decisions under real load, and tune based on data. The article's examples are starting points, not destinations.
+
+
The tuning process is iterative. Start with defaults, run under real load, and watch what happens. If you're scaling too slowly, reduce stabilization windows and increase policy percentages. If you're oscillating, increase stabilization and lower your target utilization. If you're wasting money on idle pods, tighten scale-down policies or raise target utilization. There's no universal "best" configuration — only the configuration that matches your traffic pattern.
diff --git a/src/content/articles/kubernetes-ingress-gateway-api-comparison-migration/index.mdx b/src/content/articles/kubernetes-ingress-gateway-api-comparison-migration/index.mdx
index 8df4c1e9..2625ec4d 100644
--- a/src/content/articles/kubernetes-ingress-gateway-api-comparison-migration/index.mdx
+++ b/src/content/articles/kubernetes-ingress-gateway-api-comparison-migration/index.mdx
@@ -37,6 +37,8 @@ The lesson: evaluate based on your actual requirements, not industry hype. The c
Before diving into features and migration, it helps to understand how these two APIs think about routing. Ingress is flat — one resource type handles everything. Gateway API is layered — different resource types for different concerns, managed by different teams.
+
+
### Ingress Model
Ingress uses a single resource type that combines routing rules, TLS configuration, and backend references. Everything lives in one YAML file, which makes simple cases easy but complex cases awkward. The catch is that Ingress itself only defines the _interface_ — the actual behavior depends entirely on which controller you're running. NGINX Ingress Controller interprets annotations one way; Traefik interprets them differently; some controllers ignore certain annotations entirely.
@@ -929,6 +931,26 @@ This works because both APIs can coexist on the same cluster, even with the same
The choice between Ingress and Gateway API isn't about which technology is "better"—it's about which fits your current needs and team capabilities.
+
+
Ingress is simpler, more mature, and has years of battle-tested operational patterns. If your routing needs are straightforward — host-based routing, path prefixes, TLS termination in a single namespace — Ingress does the job with less complexity.
Gateway API is more powerful and more portable. Its layered architecture enables multi-team self-service with guardrails. Native support for traffic splitting, header manipulation, and non-HTTP protocols eliminates the annotation sprawl that makes Ingress configurations fragile. If you need these capabilities, Gateway API delivers them in a standardized way that works across controllers.
diff --git a/src/content/articles/kubernetes-multi-cluster-fleet-management-configuration/index.mdx b/src/content/articles/kubernetes-multi-cluster-fleet-management-configuration/index.mdx
index 23a433c7..1adc248e 100644
--- a/src/content/articles/kubernetes-multi-cluster-fleet-management-configuration/index.mdx
+++ b/src/content/articles/kubernetes-multi-cluster-fleet-management-configuration/index.mdx
@@ -37,6 +37,8 @@ Kubernetes Federation (KubeFed) was the original attempt to solve multi-cluster
In practice, Federation never gained traction. The v1 implementation was deprecated in 2018, and KubeFed v2 saw limited adoption before being archived in 2022. The problems were fundamental: Federation tried to abstract away cluster differences, but real-world multi-cluster deployments _need_ those differences. A one-size-fits-all API couldn't handle the nuanced configuration variations between dev and production, between AWS and on-prem, between US-East and EU-West.
+
+
The industry moved toward a different model: GitOps-based fleet management. Instead of a control plane pushing identical configs everywhere, each cluster pulls its configuration from Git, with templating systems handling the variations. This approach — implemented by tools like ArgoCD ApplicationSets, Flux, and Rancher Fleet — provides the consistency benefits Federation promised while preserving the flexibility real deployments require.
## Multi-Cluster Architectures
@@ -1006,6 +1008,26 @@ Progressive rollout is essential for production fleet operations. Start with a c
The shift from Kubernetes Federation to GitOps-based fleet management reflects a fundamental insight: multi-cluster consistency isn't about making clusters identical, it's about making differences _intentional_ and _documented_. Federation failed because it tried to abstract away cluster variations. Modern fleet tools succeed because they embrace variations while providing guardrails.
+
+
The practical path forward has four components. First, document your fleet topology explicitly — which clusters exist, what each serves, what should be shared versus different. This documentation becomes the foundation for your templating strategy, whether you choose Kustomize overlays or Helm values hierarchies.
Second, choose tools that match how your team thinks. ArgoCD ApplicationSets work well if you think in terms of "deploy this app to these clusters." Flux's Kustomization hierarchy fits teams who think in terms of "base config plus environment overrides plus cluster-specific tweaks." Fighting your team's mental model leads to workarounds that undermine the system.
diff --git a/src/content/articles/kubernetes-pod-disruption-budget-autoscaler-node-rotation/download.mdx b/src/content/articles/kubernetes-pod-disruption-budget-autoscaler-node-rotation/download.mdx
new file mode 100644
index 00000000..999246fb
--- /dev/null
+++ b/src/content/articles/kubernetes-pod-disruption-budget-autoscaler-node-rotation/download.mdx
@@ -0,0 +1,38 @@
+---
+title: "Download the Pod Disruption Budget Playbook"
+description: "Get the e-book: Configuring PodDisruptionBudgets that protect availability during node rotations without deadlocking autoscaler scale-downs."
+author: "kevin-brown"
+cover: "./cover.jpg"
+coverAlt: "Air traffic controller managing planes (pods) on runways (nodes) with minimum availability requirements during runway maintenance for operational continuity"
+publishDate: 2024-08-03
+isDraft: false
+fileType: "PDF"
+fileSize: "3.6 MB"
+pages: 18
+fileName: "kubernetes-pod-disruption-budget-autoscaler-node-rotation.pdf"
+---
+
+A PodDisruptionBudget that blocks every eviction doesn't protect your service — it blocks security patches, node upgrades, and capacity optimization. Platform teams discover this at 6 AM when half the cluster is stuck in "draining" state because a critical service set `minAvailable: 3` with exactly 3 replicas, all on nodes scheduled for rotation.
+
+This complete guide breaks down how PDBs actually interact with the eviction API, the cluster autoscaler, and node rotation controllers — so you can write budgets that absorb voluntary disruptions instead of preventing them.
+
+This complete guide teaches you:
+
+
+
+Download the Pod Disruption Budget Playbook now to configure budgets that keep workloads available while the cluster changes around them.
diff --git a/src/content/articles/kubernetes-pod-disruption-budget-autoscaler-node-rotation/index.mdx b/src/content/articles/kubernetes-pod-disruption-budget-autoscaler-node-rotation/index.mdx
new file mode 100644
index 00000000..64ea6465
--- /dev/null
+++ b/src/content/articles/kubernetes-pod-disruption-budget-autoscaler-node-rotation/index.mdx
@@ -0,0 +1,587 @@
+---
+title: "Disruption Budgets: Surviving Autoscaler Churn"
+description: "Configuring PodDisruptionBudgets to survive node rotations without blocking cluster operations."
+cover: "./cover.jpg"
+coverAlt: "Air traffic controller managing planes (pods) on runways (nodes) with minimum availability requirements during runway maintenance for operational continuity"
+author: "kevin-brown"
+publishDate: 2024-08-03
+tags: ["cloud-platforms","kubernetes","prometheus","aws"]
+featured: true
+---
+
+import pdbConfigDiagram from "./diagrams/pdb-configuration-decision-tree.jpg"
+import pdbEvictionDiagram from "./diagrams/pdb-eviction-decision-flow.jpg"
+
+*[API]: Application Programming Interface
+*[CA]: Cluster Autoscaler
+*[HPA]: Horizontal Pod Autoscaler
+*[K8s]: Kubernetes
+*[PDB]: Pod Disruption Budget
+*[PDBs]: Pod Disruption Budget
+*[RBAC]: Role-Based Access Control
+*[SLA]: Service Level Agreement
+
+A PodDisruptionBudget (PDB) is a native Kubernetes resource that limits how many pods can be down simultaneously during voluntary disruptions — node drains, upgrades, autoscaler scale-downs. It's the mechanism that keeps your service available while the cluster changes around it. But poorly configured PDBs create a different problem: overly strict budgets can block node rotations entirely, prevent security patches, and cause cluster autoscaler deadlocks. A PDB is a contract between workload owners and cluster operators, and like any contract, the terms matter.
+
+Here's a scenario I've seen more than once: a platform team schedules node rotation for security patching. The rotation begins at 2 AM. By 6 AM, the on-call engineer is paged — half the nodes are stuck in "draining" state.
+
+Investigation reveals a critical service with `minAvailable: 3` and 3 replicas, but all 3 pods landed on nodes scheduled for rotation. The PDB that was supposed to protect availability is now __preventing__ the security patch that protects availability. The lesson: PDBs require thinking about both directions — protecting from disruption __and__ allowing necessary operations.
+
+
+A PDB that blocks all disruptions doesn't protect your service — it protects it from getting security patches, upgrades, and capacity optimization. The goal is controlled disruption, not zero disruption.
+
+
+## PDB Fundamentals
+
+### How PDBs Work
+
+A PDB targets pods via a label selector and specifies either a minimum number that must remain available (`minAvailable`) or a maximum that can be unavailable (`maxUnavailable`). When something attempts to evict a pod — whether it's `kubectl drain`, the cluster autoscaler, or a node upgrade controller — the API server checks the PDB before allowing the eviction.
+
+```yaml title="pdb-basics.yaml"
+apiVersion: policy/v1
+kind: PodDisruptionBudget
+metadata:
+ name: api-server-pdb
+ namespace: production
+spec:
+ selector:
+ matchLabels:
+ app: api-server
+ # Minimum pods that must remain available
+ minAvailable: 2
+ # OR maximum pods that can be unavailable (pick one, not both)
+ # maxUnavailable: 1
+```
+
+Code: Basic PDB structure targeting pods with the `app: api-server` label.
+
+The PDB controller continuously tracks how many matching pods are healthy and calculates a key field: `disruptionsAllowed`. This number tells you how many pods can currently be evicted without violating the budget. If it's zero, nothing can be evicted. You can check the current state with `kubectl get pdb`:
+
+```bash
+NAME MIN AVAILABLE MAX UNAVAILABLE ALLOWED DISRUPTIONS AGE
+api-server-pdb 2 N/A 3 24h
+```
+
+Figure: PDB status showing 3 disruptions allowed (5 healthy pods minus 2 minimum).
+
+The eviction flow works like this: when a drain or autoscaler attempts to evict a pod, it creates an Eviction object through the API server. The API server checks the relevant PDB. If `disruptionsAllowed` is greater than zero, the eviction proceeds. If it's zero, the API returns a 429 (Too Many Requests) and the eviction is blocked.
+
+
+
+### minAvailable vs maxUnavailable
+
+Choosing the wrong mode is one of the most common causes of autoscaler deadlocks. The two PDB modes look similar but behave differently as your replica count changes. Consider a 5-replica deployment:
+
+
+
+The math gets interesting with percentages. `minAvailable: 80%` on 5 replicas means ceil(5 × 0.8) = 4 must stay up, allowing only 1 disruption. `maxUnavailable: 25%` means floor(5 × 0.25) = 1 can be down. Same result, but the behavior diverges as you scale.
+
+
+
+The dangerous patterns to avoid: setting `minAvailable` equal to your replica count, or setting `maxUnavailable: 0`. Both result in `disruptionsAllowed: 0`, meaning no pods can __ever__ be evicted voluntarily. Node drains will hang forever.
+
+
+Use `maxUnavailable` for most cases — it's easier to reason about ("at most 1 pod down at a time"). Use `minAvailable` when you have a hard minimum for quorum-based systems (etcd needs 2 of 3, ZooKeeper needs 2 of 3).
+
+
+## Cluster Autoscaler Interaction
+
+The cluster autoscaler respects PDBs during scale-down operations. Before removing an underutilized node, it checks whether evicting all pods on that node would violate any PDB. If it would, the node is skipped. This is usually the right behavior — you don't want cost optimization to cause outages — but it creates a common operational trap.
+
+
+
+### Scale-Down and PDBs
+
+Here's the scenario: you have 10 pods spread evenly across 5 nodes (2 pods per node), with a PDB requiring `minAvailable: 9`. The autoscaler identifies node-1 as underutilized and attempts scale-down. But evicting node-1's 2 pods would leave only 8 healthy pods, violating the PDB. The autoscaler tries node-2—same problem. Every node is blocked.
+
+The result: you're paying for 5 nodes when 3 would be enough. The PDB that protects availability is now costing you money.
+
+### Avoiding Autoscaler Deadlocks
+
+The deadlock happens when your PDB's `disruptionsAllowed` is smaller than the maximum pods on any single node. The fix is straightforward: ensure your PDB allows at least as many disruptions as the most pods you'll have on one node.
+
+
+__The autoscaler-safe formula:__ `maxUnavailable >= ceil(total_pods / node_count)`
+
+
+For 30 pods across 10 nodes (3 pods per node), you need `maxUnavailable: 4` or higher. With a 20% buffer for scheduling delays during drain, `maxUnavailable: 5` is safer.
+
+```yaml title="autoscaler-safe-pdb.yaml"
+apiVersion: policy/v1
+kind: PodDisruptionBudget
+metadata:
+ name: autoscaler-friendly-pdb
+spec:
+ selector:
+ matchLabels:
+ app: my-service
+ # 20% of pods can be unavailable
+ # For 30 pods: floor(30 * 0.2) = 6 can be down
+ # Safely covers draining any single node (3 pods)
+ maxUnavailable: "20%"
+```
+
+Code: PDB configured to allow autoscaler scale-down.
+
+The percentage approach scales automatically as your deployment grows. If you scale to 60 pods across 20 nodes, 20% still allows 12 disruptions — more than enough to drain any node.
+
+
+The most common PDB deadlock: you have N nodes with M pods spread evenly, and your PDB requires more than (total_pods - pods_per_node) to be available. Calculate your PDB settings based on your cluster topology, not just your application needs.
+
+
+## Node Rotation Strategies
+
+Node rotations happen for many reasons: security patches, Kubernetes version upgrades, instance type changes, or AMI updates. The key to smooth rotations is working __with__ PDBs rather than fighting them.
+
+### Rolling Node Updates
+
+The recommended approach is surge-then-drain: add new nodes first, then drain old ones sequentially. This maintains capacity throughout the rotation and gives PDBs room to work.
+
+
+
+This sequence maintains capacity throughout the rotation. Cordoning doesn't evict anything — it just marks nodes as unschedulable. Step 3 is where PDBs do their job, ensuring availability while pods migrate. If you're using managed node groups, the autoscaler handles step 4 automatically.
+
+The drain command respects PDBs by default:
+
+```bash
+kubectl drain node-1 \
+ --delete-emptydir-data \
+ --ignore-daemonsets \
+ --timeout=600s
+```
+
+Code: Basic drain command with common flags.
+
+The `--timeout` flag is critical. Without it, a blocked drain will hang forever. With it, you'll get an error after 10 minutes that tells you which PDB is blocking.
+
+### Handling Stuck Drains
+
+When a drain times out, the first step is identifying what's blocking it:
+
+```bash
+# Check which PDBs have zero disruptions allowed
+kubectl get pdb -A -o wide | grep "0"
+
+# Find pods still on the draining node
+kubectl get pods -A --field-selector spec.nodeName=node-1
+```
+
+Code: Diagnosing a stuck drain.
+
+You're looking for PDBs where the `ALLOWED DISRUPTIONS` column shows `0` — those are the ones blocking your drain. Cross-reference with the pods still on the node to identify the culprit.
+
+Once you've identified the blocking PDB, you have several options depending on the situation:
+
+disruptionsAllowed is zero because pods are failing health checks, fix the health issue. Once pods are healthy, the PDB will allow disruptions again.',
+ },
+ {
+ lead: 'Scale up the deployment',
+ text: 'If you have minAvailable: 3 but only 3 replicas and one is unhealthy, add a fourth replica. Once it\'s ready, you\'ll have headroom to drain.',
+ },
+ {
+ lead: 'Temporarily relax the PDB',
+ text: 'For planned maintenance windows, you can patch the PDB to be less strict:',
+ },
+ ]}
+/>
+
+```bash
+#!/bin/bash
+
+# Relax PDB for maintenance
+kubectl patch pdb my-service-pdb -p '{"spec":{"minAvailable":1}}'
+
+# Perform drain
+kubectl drain node-1 --delete-emptydir-data --ignore-daemonsets
+
+# Restore original PDB
+kubectl patch pdb my-service-pdb -p '{"spec":{"minAvailable":2}}'
+```
+
+Code: Temporarily relaxing a PDB for maintenance.
+
+kubectl delete pod --force --grace-period=0. This ignores PDBs completely — the pod just disappears.',
+ },
+ ]}
+/>
+
+
+
+
+Never use `--force` in production node drains unless you've exhausted all other options and accepted the availability impact. Forced deletion bypasses PDBs entirely and can cause cascading failures in quorum-based systems.
+
+
+## Production PDB Patterns
+
+After working through the mechanics, let's look at what actually works in production. The right PDB depends on your workload type.
+
+### Recommended Configurations
+
+__Stateless web services__ benefit from percentage-based PDBs that scale with your deployment:
+
+```yaml title="stateless-pdb.yaml"
+apiVersion: policy/v1
+kind: PodDisruptionBudget
+metadata:
+ name: web-service-pdb
+spec:
+ selector:
+ matchLabels:
+ app: web-service
+ maxUnavailable: "25%"
+```
+
+Code: PDB for stateless services allowing 25% unavailable.
+
+Twenty-five percent is a good default — it balances availability (75% always up) with operational flexibility (multiple pods can be evicted simultaneously during node drains).
+
+__Stateful and quorum-based services__ like etcd, ZooKeeper, or Redis clusters need `minAvailable` set to their quorum size:
+
+```yaml title="quorum-pdb.yaml"
+apiVersion: policy/v1
+kind: PodDisruptionBudget
+metadata:
+ name: etcd-pdb
+spec:
+ selector:
+ matchLabels:
+ app: etcd
+ # 3-node cluster needs 2 for quorum
+ minAvailable: 2
+```
+
+Code: PDB for a 3-node etcd cluster requiring quorum.
+
+__DaemonSets__ are tricky because there's one pod per node. A percentage-based PDB controls how many nodes can drain simultaneously:
+
+```yaml title="daemonset-pdb.yaml"
+apiVersion: policy/v1
+kind: PodDisruptionBudget
+metadata:
+ name: fluentd-pdb
+spec:
+ selector:
+ matchLabels:
+ app: fluentd
+ # Allow 10% of nodes to drain at once
+ maxUnavailable: "10%"
+```
+
+Code: PDB for a logging DaemonSet.
+
+The right percentage depends on your tolerance for gaps. For logging agents like Fluentd, 10% means brief log gaps during rotation — usually acceptable. For monitoring agents or security daemons where gaps are more problematic, use 5%. For non-critical DaemonSets, 20-25% speeds up rotations significantly.
+
+maxUnavailable: 1 doesn\'t prevent disruption — it just makes the eviction use the eviction API instead of direct deletion. This can be useful for visibility and for systems that watch eviction events, but it doesn\'t provide availability protection. If you need zero downtime, run more replicas.',
+ },
+ {
+ lead: 'Batch jobs and CronJobs',
+ text: 'Generally shouldn\'t have PDBs. Jobs should be restartable by design, and a PDB on a job creates operational headaches without providing real protection.',
+ },
+ ]}
+/>
+
+### Anti-Patterns to Avoid
+
+Several PDB configurations look reasonable but cause problems:
+
+minAvailable: 3, nothing can ever be evicted. This is the most common PDB misconfiguration.',
+ },
+ {
+ lead: 'Percentages that round badly.',
+ text: 'minAvailable: 90% with 3 replicas means ceil(2.7) = 3 pods required — blocking all evictions. Use absolute numbers for small deployments.',
+ },
+ {
+ lead: 'Multiple overlapping PDBs.',
+ text: 'If a pod matches two PDBs, both must allow the disruption. This is stricter than either PDB alone and often catches teams by surprise. For example: Team A creates a PDB for app: payments allowing 1 disruption, Team B creates a PDB for tier: critical allowing 2 disruptions. Pods with both labels need both PDBs to allow eviction simultaneously — effectively the stricter of the two.',
+ },
+ ]}
+/>
+
+
+
+
+The golden rule: always have at least 1 more replica than your minAvailable requires, AND ensure maxUnavailable is at least as large as your maximum pods-per-node. This prevents both availability violations and autoscaler deadlocks.
+
+
+## Monitoring and Alerting
+
+PDBs fail silently. A misconfigured PDB doesn't cause an immediate outage — it causes the __next__ node rotation to hang at 3 AM. Monitoring catches problems before they block operations.
+
+### PDB Health Metrics
+
+The kube-state-metrics project exposes PDB status as Prometheus metrics (ensure kube-state-metrics is deployed and scraped by Prometheus — it's included in most monitoring stacks like kube-prometheus-stack). The key metrics to watch:
+
+kube_poddisruptionbudget_status_pod_disruptions_allowed',
+ text: 'How many pods can currently be evicted',
+ },
+ {
+ lead: 'kube_poddisruptionbudget_status_current_healthy ',
+ text: 'Pods currently passing health checks',
+ },
+ {
+ lead: 'kube_poddisruptionbudget_status_desired_healthy ',
+ text: 'Minimum required by the PDB',
+ },
+ ]}
+/>
+
+The most important alert is for PDBs that are blocking all disruptions:
+
+```yaml title="pdb-alerts.yaml"
+apiVersion: monitoring.coreos.com/v1
+kind: PrometheusRule
+metadata:
+ name: pdb-alerts
+spec:
+ groups:
+ - name: pdb.rules
+ rules:
+ - alert: PDBBlockingDisruptions
+ expr: kube_poddisruptionbudget_status_pod_disruptions_allowed == 0
+ for: 30m
+ labels:
+ severity: warning
+ annotations:
+ summary: "PDB {{ $labels.poddisruptionbudget }} blocking all disruptions"
+ description: "PDB in {{ $labels.namespace }} has disruptionsAllowed=0 for 30+ minutes"
+
+ - alert: PDBViolated
+ expr: |
+ kube_poddisruptionbudget_status_current_healthy
+ < kube_poddisruptionbudget_status_desired_healthy
+ for: 5m
+ labels:
+ severity: critical
+ annotations:
+ summary: "PDB {{ $labels.poddisruptionbudget }} violated"
+ description: "Current healthy pods below minimum required"
+```
+
+Code: Essential PDB alerts for Prometheus.
+
+The 30-minute threshold for `PDBBlockingDisruptions` balances signal quality with early warning. Brief blocking during deployments is normal; sustained blocking indicates a problem.
+
+The `PDBViolated` alert is more urgent — it means you're already below your availability target, likely due to pod failures unrelated to eviction.
+
+### Health Thresholds
+
+Use these thresholds to assess PDB health at a glance:
+
+disruptionsAllowed',
+ td: ['> 20% of pods', '1-20% of pods', '0'],
+ },
+ {
+ th: 'currentHealthy vs desired ',
+ td: ['Equal', '---', 'Below'],
+ },
+ {
+ th: 'Blocking duration',
+ td: ['< 5 min', '5-30 min', '> 30 min'],
+ },
+ {
+ th: 'Flexibility ratio',
+ td: ['> 25%', '10-25%', '< 10%'],
+ },
+ ],
+ },
+ figure: 'PDB health thresholds for monitoring.',
+ }}
+/>
+
+The flexibility ratio (disruptions allowed divided by total pods) tells you whether a PDB will block autoscaler scale-down. Below 20%, you're likely to hit deadlocks.
+
+
+Alert on PDBs blocking disruptions for more than 30 minutes — this indicates either a misconfigured PDB or unhealthy pods. Either way, it needs human attention before it blocks the next operational event.
+
+
+## Conclusion
+
+PDBs are contracts between workload owners and platform operators. The goal is controlled disruption, not zero disruption.
+
+
+
+Configure PDBs to allow at least enough disruptions for single-node drains — that's the minimum for cluster operations to work. Monitor for PDBs that block disruptions, and have clear procedures for resolving stuck drains when they happen.
+
+
+If your platform team is constantly battling PDBs during node rotations, the PDBs are misconfigured — not too loose, but too strict.
+
+
+A cluster that can't be maintained isn't a reliable cluster. PDBs that block security patches, version upgrades, and capacity optimization aren't protecting availability — they're trading one kind of risk for another. The best PDB configuration is invisible: it protects your workloads during operations without anyone noticing. That 6 AM page about stuck nodes? With proper PDB configuration, it doesn't happen.
diff --git a/src/content/articles/kubernetes-pod-resource-requests-limits-qos-classes/index.mdx b/src/content/articles/kubernetes-pod-resource-requests-limits-qos-classes/index.mdx
index 86b3211a..6d4a2a7c 100644
--- a/src/content/articles/kubernetes-pod-resource-requests-limits-qos-classes/index.mdx
+++ b/src/content/articles/kubernetes-pod-resource-requests-limits-qos-classes/index.mdx
@@ -64,6 +64,8 @@ CPU is a __compressible__ resource. When a pod exceeds its CPU limit, Kubernetes
Under the hood, CPU limits use Linux's Completely Fair Scheduler (CFS) quotas. The kernel gives each container a time budget per scheduling period (typically 100ms). A pod with a 500m CPU limit gets 50ms of CPU time per 100ms period. Use it up and the pod waits until the next period.
+
+
The symptoms of CPU throttling are subtle: increased latency during traffic spikes, timeouts on CPU-bound operations, slow container startup, and health check failures. Unlike OOM kills, there's no clear error message — just degraded performance.
You can detect throttling with Prometheus:
@@ -800,6 +802,26 @@ Most organizations run at 20-30% resource efficiency. Moving to 50-60% can cut i
Remember the 3 AM evictions from the introduction? The well-behaved pods died because they had requests without limits — Burstable QoS, middle of the eviction queue. The pods with no resource specs (BestEffort) should have been evicted first, but the eviction algorithm also considers usage relative to requests. The fix isn't complicated, but it requires understanding the model.
+
+
Pod resource configuration comes down to two contracts: __requests__ are your promise to the scheduler about what you need, and __limits__ are your promise to the kernel about what you'll never exceed. The scheduler uses requests to place pods; the kernel uses limits to enforce boundaries. Get these wrong and you'll either waste money (over-requesting), starve neighbors (under-limiting), or face surprise evictions (wrong QoS class).
QoS class — Guaranteed, Burstable, or BestEffort — is derived from your resource specs, not set directly. It determines who dies first when nodes run low. If you care about a workload surviving node pressure, you need to care about its QoS class. Guaranteed (requests equal limits) for critical services, Burstable (limits greater than requests) for everything else that matters.
diff --git a/src/content/articles/kubernetes-secrets-external-secrets-operator-csi-vault/index.mdx b/src/content/articles/kubernetes-secrets-external-secrets-operator-csi-vault/index.mdx
index 4cbce7e9..e421c841 100644
--- a/src/content/articles/kubernetes-secrets-external-secrets-operator-csi-vault/index.mdx
+++ b/src/content/articles/kubernetes-secrets-external-secrets-operator-csi-vault/index.mdx
@@ -137,6 +137,8 @@ spec:
```
Code: Native Kubernetes secrets usage.
+
+
When mounted as a volume, each key in the Secret becomes a separate file. In this example, the pod gets `/etc/secrets/username` and `/etc/secrets/password`—plain text files containing the decoded secret values. Your application reads these files directly.
@@ -978,6 +980,26 @@ ESO sync success doesn't mean Vault is healthy — it means the last sync worked
Secret injection is ultimately about failure modes. ESO fails silently — your pods keep running with stale secrets until you notice sync errors in monitoring. CSI driver fails loudly — pods don't start, deployments block, and you know immediately something is wrong. Init containers fail however you code them.
+
+
For most organizations, ESO is the right default. It's operationally simple, GitOps-friendly, and its failure mode (staleness) is tolerable for the vast majority of workloads. Reserve CSI driver for applications with strict compliance requirements or real-time credential needs. Use init containers only when you need behavior that neither operator provides.
diff --git a/src/content/articles/legacy-code-testing-characterization-tests-seams/index.mdx b/src/content/articles/legacy-code-testing-characterization-tests-seams/index.mdx
index f426f1d6..2f47a2bf 100644
--- a/src/content/articles/legacy-code-testing-characterization-tests-seams/index.mdx
+++ b/src/content/articles/legacy-code-testing-characterization-tests-seams/index.mdx
@@ -203,6 +203,8 @@ Characterization tests are not about correctness — they're about documenting c
Michael Feathers introduced the concept of seams in __Working Effectively with Legacy Code__, and it remains the most useful mental model for making untestable code testable. A seam is a place where you can alter program behavior without editing the code at that location. The seam itself doesn't change — you change behavior at what Feathers calls the "enabling point."
+
+
The distinction matters because legacy code often can't be edited safely. You don't have tests, so any edit risks breaking something. Seams let you substitute behavior for testing purposes without touching the production logic you're trying to protect.
Consider a method that sends emails. The email-sending code is deep inside a 500-line method that also processes orders, updates inventory, and logs analytics. You can't easily extract the email logic — too risky without tests. But if you can find a seam, you can replace the email sender with a test double that captures what __would__ have been sent, without changing the method itself.
@@ -1217,6 +1219,26 @@ Don't aim for 100% coverage on legacy code. Aim for coverage where you need conf
Legacy code isn't a curse — it's working software that's earned its complexity through years of real-world use. The techniques in this article let you approach it systematically rather than fearfully.
+
+
Start with characterization tests. Run the code, observe what happens, write it down. Don't judge whether the behavior is correct — just document it. These tests become your safety net, catching unintended changes during refactoring.
Find seams where you can alter behavior without modifying code. Object seams let you substitute implementations. Link seams let you intercept at module boundaries. Preprocessor seams let you swap behavior based on environment. Every language has them; you just need to recognize them.
diff --git a/src/content/articles/monorepo-affected-builds-remote-caching-ci-optimization/index.mdx b/src/content/articles/monorepo-affected-builds-remote-caching-ci-optimization/index.mdx
index ce8e6357..5b0a26f0 100644
--- a/src/content/articles/monorepo-affected-builds-remote-caching-ci-optimization/index.mdx
+++ b/src/content/articles/monorepo-affected-builds-remote-caching-ci-optimization/index.mdx
@@ -37,6 +37,8 @@ Affected builds work by analyzing the dependency graph — the directed acyclic
A monorepo typically contains two categories of packages: applications (deployable artifacts) and libraries (shared code). The dependency relationships between them form a hierarchy. Applications sit at the top, depending on libraries. Libraries depend on other libraries, forming chains. At the bottom are leaf libraries with no internal dependencies.
+
+
Three types of dependencies matter for affected calculation. __Direct dependencies__ are explicit imports — if `app-web` imports from `@libs/ui-components`, that's a direct dependency. __Transitive dependencies__ flow through the graph — if `ui-components` depends on `design-tokens`, then `app-web` transitively depends on `design-tokens` too. __Dev dependencies__ are needed for development and testing but don't affect production builds.
The impact of a change depends on where it lands in the graph. Change a leaf library like `@libs/utils` that everything depends on, and the entire monorepo rebuilds. Change a library that only one application uses, and only that application rebuilds. This is why dependency graph design matters — poorly structured dependencies create "rebuild everything" scenarios even for small changes.
@@ -838,6 +840,26 @@ Track CI metrics over time. A sudden drop in cache hit rate or spike in duration
Monorepo CI optimization isn't a single technique — it's a stack of complementary approaches that compound. Affected builds analyze the dependency graph to skip packages that couldn't possibly be impacted by a change. Remote caching eliminates redundant work by sharing build outputs across developers and CI runners. Parallel execution runs remaining tasks concurrently. Distributed execution spreads work across multiple agents.
+
+
Each level provides meaningful speedup on its own. Together, they transform CI from a 45-minute bottleneck into a 4-minute feedback loop. The exact numbers depend on your repository structure and change patterns, but order-of-magnitude improvements are typical.
The implementation path is straightforward. Start with affected builds — configure Nx or Turborepo to calculate what changed and skip the rest. Add remote caching to share results across your team. Tune your input specifications to maximize cache hit rates. Then, if CI is still slower than you'd like, introduce parallelization and distribution.
diff --git a/src/content/articles/mtls-certificate-rotation-service-mesh-authentication/index.mdx b/src/content/articles/mtls-certificate-rotation-service-mesh-authentication/index.mdx
index bc0db080..3e758960 100644
--- a/src/content/articles/mtls-certificate-rotation-service-mesh-authentication/index.mdx
+++ b/src/content/articles/mtls-certificate-rotation-service-mesh-authentication/index.mdx
@@ -139,6 +139,8 @@ For mTLS, the Extended Key Usage must include both `serverAuth` and `clientAuth`
Now that we understand certificate structure, the next question is: who signs these certificates, and how do services decide which certificates to trust?
+
+
### Certificate Authority Chains
The CA hierarchy you choose affects every aspect of mTLS operations: how certificates are issued, how rotation works, what happens when a CA is compromised, and how difficult cross-cluster communication becomes. Getting this wrong early creates painful migrations later.
@@ -910,6 +912,26 @@ Switching to PERMISSIVE mode during an incident allows plaintext traffic, which
Enabling mTLS is a configuration change. Operating it reliably is an ongoing commitment to understanding certificate lifecycles, building automation for rotation, monitoring for expiration failures, and having runbooks ready for when things go wrong.
+
+
The trust hierarchy you choose affects everything downstream. Two-tier is simple but offers no isolation. Three-tier provides blast radius containment but requires more coordination during rotation. Federated trust gives you full isolation between clusters at the cost of explicit trust management.
Certificate TTLs are a tradeoff. Short-lived certificates (24 hours) limit the damage from a compromised certificate but require robust automation. Longer certificates (7 days) are more forgiving of automation failures but increase your exposure window.
diff --git a/src/content/articles/nginx-haproxy-reverse-proxy-production-tuning/index.mdx b/src/content/articles/nginx-haproxy-reverse-proxy-production-tuning/index.mdx
index d943e3af..c661bcc6 100644
--- a/src/content/articles/nginx-haproxy-reverse-proxy-production-tuning/index.mdx
+++ b/src/content/articles/nginx-haproxy-reverse-proxy-production-tuning/index.mdx
@@ -93,6 +93,8 @@ If you're proxying gRPC traffic, HTTP/2 is mandatory — gRPC requires it. Make
Timeouts are the most common source of proxy-related outages, and the defaults are almost never right for production. Nginx defaults most timeouts to 60 seconds — generous enough to hide problems during development, short enough to cause 502s when a backend occasionally takes 65 seconds. HAProxy is worse: many timeouts have __no default__, meaning connections can hang indefinitely if you don't configure them.
+
+
The key insight is that timeouts should match your traffic patterns, not arbitrary round numbers. A health check endpoint should respond in milliseconds; a report generation endpoint might legitimately take 5 minutes. Using the same timeout for both means either your health checks are too slow to detect failures, or your reports timeout prematurely.
### Nginx Timeout Hierarchy
@@ -1346,6 +1348,26 @@ The key is testing configuration syntax __before__ reloading. A syntax error dur
Proxy defaults are starting points, not production configurations. Nginx's 60-second timeouts and HAProxy's unlimited defaults exist to avoid breaking things during development — they're the wrong choices for production.
+
+
The tuning process follows a pattern: establish baseline metrics, identify bottlenecks, adjust configuration, measure again. Timeouts should match your traffic patterns — slow report endpoints need longer read timeouts, health checks need aggressive timeouts that fail fast. Buffers should accommodate your payloads without wasting memory. Connection pools should be sized for your request rate and backend count.
The goal isn't a perfectly optimized configuration — it's a __resilient__ one. Your proxy should handle normal traffic with good performance, absorb traffic spikes without dropping connections, and shed load gracefully when backends struggle. Test under failure conditions: simulate slow backends, connection storms, and oversized payloads. The problems you find in staging won't page you at 3 AM.
diff --git a/src/content/articles/on-call-rotation-small-teams-sustainable-coverage/index.mdx b/src/content/articles/on-call-rotation-small-teams-sustainable-coverage/index.mdx
index 7ab21402..697aaac5 100644
--- a/src/content/articles/on-call-rotation-small-teams-sustainable-coverage/index.mdx
+++ b/src/content/articles/on-call-rotation-small-teams-sustainable-coverage/index.mdx
@@ -131,6 +131,8 @@ A three-person team should never have more than one person on vacation at the sa
Alert quality makes or breaks small team on-call. You can have perfect rotation schedules and beautiful runbooks, but if half your pages are noise, your team will burn out anyway. The math is simple: a three-person team can sustainably handle maybe 3-5 pages per person per week. Waste that budget on false positives and auto-resolving transients, and you've got nothing left for real incidents.
+
+
### Alert Severity Levels
Every alert needs a severity level, and that level determines whether it pages, when it pages, and how fast you need to respond. For small teams, I use four levels:
@@ -793,6 +795,26 @@ The goal isn't zero alerts — it's zero unnecessary alerts. A small team can su
The team I mentioned at the start — the one with 47 pages in a week — didn't fix their on-call by hiring more people. They fixed it by being honest about what actually needed a human at 3 AM. The answer was far less than they'd assumed.
+
+
That's the core insight of small team on-call: constraints force clarity. Large organizations can absorb bad alerting by spreading it across enough people that no individual notices the rot. A team of three can't hide from their mistakes. Every unnecessary page is felt. Every burned-out engineer is visible. Every process failure has immediate consequences.
This pain is useful. It creates pressure to build systems that genuinely don't need constant human intervention — not systems that tolerate human intervention because there are enough humans available. The team of three that gets on-call right builds better automation, writes clearer runbooks, and maintains tighter alert hygiene than most teams three times their size.
diff --git a/src/content/articles/opa-conftest-policy-as-code-infrastructure-guardrails/index.mdx b/src/content/articles/opa-conftest-policy-as-code-infrastructure-guardrails/index.mdx
index f974084a..61c39e50 100644
--- a/src/content/articles/opa-conftest-policy-as-code-infrastructure-guardrails/index.mdx
+++ b/src/content/articles/opa-conftest-policy-as-code-infrastructure-guardrails/index.mdx
@@ -39,6 +39,8 @@ The target architecture runs Conftest against Terraform plans and Kubernetes man
OPA is a general-purpose policy engine that decouples policy decisions from policy enforcement. You feed it structured data (JSON), it evaluates policies written in Rego, and it returns decisions. The engine itself is stateless — policies and data define behavior.
+
+
Three deployment modes serve different use cases:
+
Start with five critical policies that run in under two seconds. Get adoption. Add coverage. The fastest path to comprehensive guardrails runs through developer trust.
diff --git a/src/content/articles/openapi-spec-documentation-sdk-generation-validation/index.mdx b/src/content/articles/openapi-spec-documentation-sdk-generation-validation/index.mdx
index 074daf78..ac005d26 100644
--- a/src/content/articles/openapi-spec-documentation-sdk-generation-validation/index.mdx
+++ b/src/content/articles/openapi-spec-documentation-sdk-generation-validation/index.mdx
@@ -27,6 +27,8 @@ The biggest OpenAPI mistake: generating a spec from existing code and calling it
If you've worked with OpenAPI specs before, you know they can get unwieldy fast. A moderately complex API generates a YAML file that scrolls forever, and finding anything requires a mental map of where things live. Understanding the structure matters because it determines whether your spec stays maintainable or becomes a 3,000-line file nobody wants to touch.
+
+
### The Building Blocks
An OpenAPI spec has four top-level sections that matter:
@@ -937,6 +939,26 @@ The difference between OpenAPI that delivers value and OpenAPI that becomes shel
When the spec drives everything — documentation, SDKs, validation, contract tests — you get consistency for free. Change the spec, regenerate artifacts, deploy. Documentation can't drift because it's generated. SDKs can't disagree with the server because they come from the same source. Validation can't miss edge cases the spec covers because it reads the spec directly.
+
+
The setup cost is real. You'll spend time choosing generators, configuring linters, wiring up CI pipelines. But that cost is paid once. The alternative — manually maintaining documentation, hand-coding SDKs, duplicating validation logic — is paid continuously, and it compounds as your API grows and your consumer base expands.
If you take one thing from this article: treat your OpenAPI spec as infrastructure, not documentation. The spec isn't describing your API — it __is__ your API contract, and everything else flows from that.
diff --git a/src/content/articles/opentelemetry-span-design-granularity-overhead/index.mdx b/src/content/articles/opentelemetry-span-design-granularity-overhead/index.mdx
index 72c2fc00..6f7a1372 100644
--- a/src/content/articles/opentelemetry-span-design-granularity-overhead/index.mdx
+++ b/src/content/articles/opentelemetry-span-design-granularity-overhead/index.mdx
@@ -382,6 +382,8 @@ function validateOrder(order: Order): ValidationResult {
```
Code: Using spans for I/O, events for milestones, attributes for metadata.
+
+
Notice that `validateOrder()` doesn't get a span — it's pure computation. The validation timing is captured as an event attribute if you need it. The database insert and payment call __do__ get spans because they're I/O operations where latency matters.
@@ -1025,6 +1027,26 @@ For batch processing: one span for the batch, events for individual failures, at
Span design is an engineering tradeoff: visibility versus overhead, granularity versus readability, detail versus cost. The goal isn't maximum spans — it's enough spans to debug problems efficiently.
+
+
Instrument service boundaries, I/O operations, and significant business logic. Use events for milestones within spans. Use attributes for metadata that helps filtering and debugging. Sample aggressively in high-throughput services — you don't need every trace, just enough to catch problems. Follow naming conventions so waterfalls tell a story at a glance.
diff --git a/src/content/articles/performance-testing-load-models-benchmark-accuracy/index.mdx b/src/content/articles/performance-testing-load-models-benchmark-accuracy/index.mdx
index 57729665..f86b7f30 100644
--- a/src/content/articles/performance-testing-load-models-benchmark-accuracy/index.mdx
+++ b/src/content/articles/performance-testing-load-models-benchmark-accuracy/index.mdx
@@ -45,6 +45,8 @@ A load model defines _what_ you're testing. It's the specification that turns "t
Before diving into load model design, a quick note on tooling. I use k6 for load testing, integrated into CI/CD pipelines. k6 scripts are JavaScript/TypeScript, which makes them easy to version control and review alongside application code. For alternatives, Locust (Python) and Gatling (Scala) are solid choices with their own ecosystems. Artillery is another JavaScript option with good AWS integration.
+
+
The bigger question is where to run these tests. Quick regression checks (2-5 minutes, moderate load) can run on CI runners against ephemeral environments. But serious performance testing — the kind that finds saturation points and validates capacity — needs dedicated infrastructure. The load generators need enough resources to not become the bottleneck, and the system under test needs to match production specifications. Cloud providers offer dedicated performance testing services (AWS has Distributed Load Testing, Azure has Load Testing), or you can provision your own infrastructure with consistent instance types. The key is reproducibility: if your test environment varies between runs, your results will too.
### Anatomy of a Load Model
@@ -937,6 +939,26 @@ Compare against baselines with statistical significance, not just raw numbers. A
Trustworthy performance testing requires getting several things right simultaneously. The load model must match production — traffic patterns, endpoint distribution, think time, and arrival distribution all matter. Warmup must complete before measurement begins, or you're measuring startup behavior instead of steady-state capacity. The test environment must match production specifications closely enough that bottlenecks appear in the same places. Statistical analysis must account for the non-normal distributions that latency data always exhibits.
+
+
The mistakes that undermine benchmarks follow predictable patterns. Open-loop load generation prevents the coordinated omission trap. Tests long enough to observe garbage collection cycles and cache behavior produce stable results. Multiple runs with statistical comparison separate signal from noise. Production-equivalent environments ensure bottlenecks appear where they'll actually occur.
Build your performance testing infrastructure incrementally. Start with quick PR checks that catch obvious regressions—2 minutes, moderate load, comparison against baseline with statistical significance. Add nightly full-suite runs on dedicated hardware that match production specs. Store historical data to detect gradual degradation. Invest in production traffic capture and replay for maximum realism.
diff --git a/src/content/articles/platform-architecture-control-plane-data-plane-separation/index.mdx b/src/content/articles/platform-architecture-control-plane-data-plane-separation/index.mdx
index 98583039..b0910b4b 100644
--- a/src/content/articles/platform-architecture-control-plane-data-plane-separation/index.mdx
+++ b/src/content/articles/platform-architecture-control-plane-data-plane-separation/index.mdx
@@ -180,6 +180,8 @@ Each layer has different change velocities and scaling needs. A well-designed pl
Control plane and data plane separation exists to serve multi-tenancy. Without multiple teams sharing the platform, you don't need the complexity — a single team can tolerate tighter coupling. But once you're building for multiple tenants, the separation enables isolation patterns that would be impossible otherwise.
+
+
The fundamental question: how much isolation do tenants need, and what are you willing to pay for it? The answer shapes everything from cost structure to operational complexity.
### Tenancy Patterns
@@ -718,6 +720,26 @@ The developer interface is the contract between platform team and product teams.
Control plane and data plane separation is the architectural foundation for scalable, multi-tenant platforms. The control plane (configuration, policy, orchestration) optimizes for consistency. The data plane (workloads, traffic, compute) optimizes for throughput. Separating them enables independent scaling, isolated failures, and evolution without breaking contracts.
+
+
The investment pays off as adoption grows. Multi-tenancy becomes manageable with designed-in isolation boundaries. Scaling becomes predictable when you understand what drives load in each plane. Failures stay contained because blast radius is part of the architecture.
The abstractions matter more than initial deployment topology. You can deploy together at first, but APIs, resource boundaries, and tenancy models need to support eventual separation. Retrofitting these later is expensive — in engineering time, migration complexity, and operational risk.
diff --git a/src/content/articles/platform-engineering-metrics-lead-time-developer-friction/index.mdx b/src/content/articles/platform-engineering-metrics-lead-time-developer-friction/index.mdx
index 60caef20..197e81a1 100644
--- a/src/content/articles/platform-engineering-metrics-lead-time-developer-friction/index.mdx
+++ b/src/content/articles/platform-engineering-metrics-lead-time-developer-friction/index.mdx
@@ -181,6 +181,8 @@ Ticket volume is a lagging indicator — it tells you friction exists but not wh
Platform metrics come from multiple systems that don't naturally talk to each other. You need data from deployment systems (ArgoCD, GitHub Actions), ticketing systems (Jira, ServiceNow), your platform portal, and HR systems for onboarding metrics. The challenge is getting these into a unified view.
+
+
The collection pattern that works: event-driven ingestion into a stream (Kafka or Kinesis), enrichment with team and service metadata, aggregation into time-series storage, and visualization through dashboards. Raw events go to a data lake for historical analysis; aggregated metrics go to a time-series database for dashboards.
+
If you're starting from scratch, don't try to build everything at once. Start with four baseline metrics you can measure today, even imperfectly: time to first deployment (ask new hires), tickets per developer (query your ticketing system), deployment frequency (check your CI logs), and a simple quarterly NPS survey. Instrument as you go, automate what you can, and add sophistication over time. A spreadsheet tracking the right metrics beats a sophisticated dashboard tracking the wrong ones.
diff --git a/src/content/articles/postgresql-connection-pooling-saturation-sizing/index.mdx b/src/content/articles/postgresql-connection-pooling-saturation-sizing/index.mdx
index d6321835..d0e8eec5 100644
--- a/src/content/articles/postgresql-connection-pooling-saturation-sizing/index.mdx
+++ b/src/content/articles/postgresql-connection-pooling-saturation-sizing/index.mdx
@@ -40,6 +40,8 @@ After adding PgBouncer, properly sizing pools, and implementing connection backp
PostgreSQL uses a process-per-connection model. When a client connects, the main __postgres process__ (the server daemon) forks a new __backend process__ dedicated to that client. Each backend handles all queries for its __connection__ (the TCP session between client and backend) until termination. In `pg_stat_activity`, these appear as rows with `backend_type = 'client backend'`. The fork-based model provides strong isolation — a crash in one backend doesn't affect others — but it comes with overhead.
+
+
Establishing a new connection involves multiple steps: TCP handshake (1ms locally, 10-100ms over network), process fork (1-5ms), authentication (1-10ms depending on method), and TLS handshake if configured (5-50ms). Total: 10-200ms for a new connection. For web applications making dozens of queries per request, creating a fresh connection each time is prohibitively expensive.
Each backend process consumes resources independent of whether it's actively running queries. The base memory footprint is roughly 5-10MB per connection, plus `work_mem` allocation when executing operations (4MB default, but can be much higher for complex queries). CPU overhead comes from OS scheduler contention — more backend processes means more context switching as the kernel cycles between them, even when most are idle.
@@ -817,6 +819,26 @@ Each failure scenario has a different detection method and response. The key is
PostgreSQL connection management is a critical but often neglected aspect of application architecture. Most teams don't think about it until 3am when the database stops accepting connections.
+
+
The key insights from this article:
+
Kubernetes adds another layer. CoreDNS intercepts queries and applies search domains — a request for `database` becomes `database.default.svc.cluster.local`, then `database.svc.cluster.local`, then `database.cluster.local` before falling through to the node's resolver. The `ndots` setting controls this behavior, and misconfiguring it causes subtle resolution failures.
The most common private DNS failures:
@@ -724,6 +726,26 @@ Common debugging issues by pattern:
Private networking provides real security benefits — no public IPs to attack, traffic contained within provider boundaries, reduced attack surface. But those benefits come with operational complexity that catches teams off guard.
+
+
The core insight: private networks fail differently than public ones. DNS resolution depends on private hosted zones and VPC associations. Routing requires explicit configuration for every destination outside your subnet. TLS certificates need private hostnames that didn't exist when you were public-only.
Build the debugging reflex before you need it: DNS → routing → connectivity → TLS → application. Each layer must work before the next can succeed. When something breaks at 3am, you don't want to be guessing.
diff --git a/src/content/articles/prometheus-high-cardinality-metrics-label-design/index.mdx b/src/content/articles/prometheus-high-cardinality-metrics-label-design/index.mdx
index 5897025d..cf88d2dc 100644
--- a/src/content/articles/prometheus-high-cardinality-metrics-label-design/index.mdx
+++ b/src/content/articles/prometheus-high-cardinality-metrics-label-design/index.mdx
@@ -146,6 +146,8 @@ Prometheus keeps metadata for ALL active series in memory, not just samples. A s
The difference between a good label and a bad one comes down to one question: can you enumerate all possible values before deployment? If you can list them exhaustively, it's probably safe. If the value set grows with your data — users, requests, sessions — it's toxic.
+
+
Good labels share four characteristics: bounded cardinality (you know the finite set of values), meaningful for aggregation (you'll actually `group by` or `sum by` this dimension), stable over time (values don't churn constantly), and shared across many series (the label adds structure, not just uniqueness).
+
The teams that run Prometheus successfully treat label design with the same rigor as database schema design. Every label must answer two questions: what bounded set of values will this have, and what aggregation does it enable? If you can't answer both, don't add the label. Use traces for high-cardinality debugging. Use exemplars to link metrics to specific requests without cardinality cost.
Defense in depth protects you when prevention fails. Normalize labels at the source with shared SDK wrappers. Transform third-party metrics with server-side relabeling. Set hard limits on samples per scrape. Alert on series count and growth rate before you hit memory limits. Keep emergency relabeling configs ready to drop problematic metrics within minutes.
diff --git a/src/content/articles/rate-limiting-token-bucket-leaky-bucket-implementation/index.mdx b/src/content/articles/rate-limiting-token-bucket-leaky-bucket-implementation/index.mdx
index f7099467..af02b3fa 100644
--- a/src/content/articles/rate-limiting-token-bucket-leaky-bucket-implementation/index.mdx
+++ b/src/content/articles/rate-limiting-token-bucket-leaky-bucket-implementation/index.mdx
@@ -212,6 +212,8 @@ resource "cloudflare_rate_limit" "api_limit" {
Code: Cloudflare rate limiting via Terraform.
+
+
Edge limiting is coarse-grained — typically by IP or geographic region. It's your first line of defense against volumetric attacks, not your primary quota enforcement.
### API Gateway
@@ -881,6 +883,26 @@ The key insights:
]}
/>
+
+
The goal isn't to reject requests — it's to shape traffic so rejection becomes rare. Design for legitimate bursts, communicate limits clearly, and monitor rejection rates. Rate limiting done right protects your service without punishing your users.
diff --git a/src/content/articles/release-quality-gates-automated-deployment-validation/index.mdx b/src/content/articles/release-quality-gates-automated-deployment-validation/index.mdx
index f8c5bb29..b3791102 100644
--- a/src/content/articles/release-quality-gates-automated-deployment-validation/index.mdx
+++ b/src/content/articles/release-quality-gates-automated-deployment-validation/index.mdx
@@ -29,6 +29,8 @@ Within three months, the gate configuration had so many exceptions it caught not
They rebuilt the system with a different philosophy: __required gates__ (critical tests, security CVEs with CVSS 9+) versus __advisory gates__ (coverage trends, performance baselines). Required gates blocked deployments. Advisory gates logged warnings and alerted, but didn't block. False positives dropped 90%, and when a required gate fired, people actually investigated because they trusted it meant something.
+
+
The measure of a good gate isn't how many deployments it blocks — it's how many real incidents it prevents relative to how many good deployments it delays. Quality gates are probabilistic safety nets, not deterministic guarantees. The goal isn't zero risk; it's catching the failures that matter while letting good deployments through quickly.
@@ -825,6 +827,26 @@ Every bypass should create a paper trail. If you're bypassing gates regularly, e
Quality gates are probabilistic safety nets, not guarantees. They work by shifting the odds — making it less likely that broken code reaches production, not impossible. Accepting this framing changes how you design them.
+
+
The tension between safety and velocity is real, but it's not a trade-off you make once. It's a dial you tune continuously. The patterns in this article give you the knobs: blocking versus advisory gates, absolute versus relative thresholds, progressive rollouts with automated rollback. Use them to find the balance that fits your risk tolerance and deployment cadence.
A few principles to carry forward:
diff --git a/src/content/articles/reverse-engineering-documentation-legacy-systems/index.mdx b/src/content/articles/reverse-engineering-documentation-legacy-systems/index.mdx
index 295004d7..2e70d187 100644
--- a/src/content/articles/reverse-engineering-documentation-legacy-systems/index.mdx
+++ b/src/content/articles/reverse-engineering-documentation-legacy-systems/index.mdx
@@ -164,6 +164,8 @@ Documentation without an owner is documentation that will rot. Every doc artifac
When documentation fails, the codebase becomes your primary source of truth. The challenge is that code tells you _what_ the system does, but rarely _why_. Code archaeology is the practice of extracting architectural understanding through systematic analysis — static analysis for structure, git history for evolution and context, and careful reading for business intent.
+
+
### Static Analysis for Structure Discovery
Before diving into individual files, you need a map of the territory. Static analysis tools can generate dependency graphs, identify module boundaries, and reveal the actual architecture (as opposed to whatever the diagrams claim).
@@ -1018,6 +1020,26 @@ Generated documentation (ERDs from database schema, dependency graphs from impor
Reverse-engineering documentation from legacy systems requires multiple approaches working together. Static code analysis reveals structure — what components exist and how they connect. Runtime observation reveals behavior — what the system actually does under real traffic. Git archaeology reveals history — how the system evolved and who knows what. Knowledge extraction from people reveals intent — the reasons behind decisions and the gotchas that never made it into writing.
+
+
The goal isn't comprehensive documentation of everything. That's neither achievable nor useful. Focus on documenting three categories:
+
### Core Entity Model
A service catalog schema centers on the __service__ as the primary entity, with relationships to teams, other services, repositories, and runtime environments. The relationships matter as much as the entities themselves.
@@ -1592,6 +1594,26 @@ Build a Grafana dashboard that shows catalog health at a glance. Include panels
A service catalog's value comes entirely from the accuracy and freshness of its metadata. Start with a minimal schema focused on your most critical use case — usually incident routing. Name, owner, tier, oncall schedule. That's enough to answer "who do I call when this breaks?" Once you've achieved high coverage with the core fields, expand to dependencies, documentation links, and domain classification.
+
+
Ownership is the single most important field. Without knowing who owns a service, you can't route incidents, assign responsibility, or track accountability. Model ownership hierarchically (team → group → org) to survive reorgs without mass updates. Build ownership transfer workflows that enforce handoffs rather than leaving orphaned services behind. Run orphan detection on a schedule and escalate services without valid owners — they're liabilities during incidents.
Dependencies require both declared sources and runtime observation. Developers declare what they __think__ they depend on; service mesh telemetry reveals what actually happens in production. When declared and observed don't match, you've found either a documentation bug or an undiscovered dependency. Both are worth investigating.
diff --git a/src/content/articles/service-decommissioning-scream-test-shutdown/index.mdx b/src/content/articles/service-decommissioning-scream-test-shutdown/index.mdx
index 49781739..1cdf9c74 100644
--- a/src/content/articles/service-decommissioning-scream-test-shutdown/index.mdx
+++ b/src/content/articles/service-decommissioning-scream-test-shutdown/index.mdx
@@ -127,6 +127,8 @@ Any one of these might be fine. Three or more together? That's a candidate. I sc
The scream test is exactly what it sounds like: turn something off and wait for someone to scream. It's the most reliable way to discover if anyone actually uses a service, because the consumers who don't know they're consumers will reveal themselves when things break.
+
+
But you can't just flip the switch and hope. A well-designed scream test gives consumers time to notice problems at each stage, provides clear information about what's happening and who to contact, and has automatic rollback triggers for when the screaming gets too loud.
### Scream Test Design
@@ -927,4 +929,24 @@ Calculate the ROI: divide annual cost savings by engineering hours spent times h
The scream test — announce, degrade, fail, shutdown — is the safest way to discover unknown consumers before they become 3 AM incidents. Combine it with traffic analysis and distributed tracing to find dependencies proactively, not reactively.
+
+
Never delete data without verified archives and a 30-day grace period. And document every decommissioning so the next one goes faster. The organization that gets good at turning things off is the organization that can move quickly when building new things.
diff --git a/src/content/articles/slo-error-budget-practical-guide/index.mdx b/src/content/articles/slo-error-budget-practical-guide/index.mdx
index bafa3884..2a38a4f5 100644
--- a/src/content/articles/slo-error-budget-practical-guide/index.mdx
+++ b/src/content/articles/slo-error-budget-practical-guide/index.mdx
@@ -112,6 +112,8 @@ The data to support these claims usually exists already. Pull the incident repor
The difference between useful SLIs and vanity metrics is whether they correlate with user experience. A dashboard full of green SLIs means nothing if users are complaining.
+
+
### The User-Centric Test
An SLI should fail when users are unhappy and pass when users are satisfied — nothing more, nothing less. This sounds obvious, but most metrics fail this test.
@@ -850,6 +852,26 @@ Once you have these two metrics, you can answer the fundamental SLO questions: "
SLOs work when they create alignment, not compliance. The goal isn't to hit arbitrary targets — it's to have a shared language for reliability that lets engineering, product, and operations make informed tradeoffs together.
+
+
Start simple: one service, two SLIs (availability and latency), one quarter of measurement. Adjust based on what you learn. The first SLO is never perfect, and that's fine. The value is in the conversation it enables, not the number itself.
After your first quarter, watch for these signals. If you're constantly breaching, your SLO is too tight — loosen it or invest in reliability. If you never breach, your SLO might be too loose, or your service might actually be reliable enough that you can afford to take more risk. If budget conversations happen but nothing changes, you have a policy problem, not a measurement problem. And if nobody looks at the dashboard, you haven't connected SLOs to decisions that matter — find a stakeholder who cares and start there.
diff --git a/src/content/articles/slsa-build-provenance-artifact-signing-supply-chain/index.mdx b/src/content/articles/slsa-build-provenance-artifact-signing-supply-chain/index.mdx
index 0625d1fb..f3db96d1 100644
--- a/src/content/articles/slsa-build-provenance-artifact-signing-supply-chain/index.mdx
+++ b/src/content/articles/slsa-build-provenance-artifact-signing-supply-chain/index.mdx
@@ -141,6 +141,8 @@ SLSA proves that an artifact came from a specific source through a specific buil
[SLSA](https://slsa.dev) defines four levels of increasing rigor. Each level adds requirements that provide incremental security improvements, but also incremental cost. The goal isn't to reach Level 4—it's to reach the level that matches your threat model without over-engineering.
+
+
### Level Requirements Overview
+
Start with SLSA Level 2 as your target. It provides authenticated provenance that blocks most external attackers without requiring significant process changes. The path is straightforward: inventory your artifacts, add keyless signing to builds, enforce verification in staging, then production, and continuously harden.
Use Cosign for signing — it eliminates key management through identity-based keyless signatures. Add verification at deployment time using Kubernetes admission control or CI/CD gates. Layer in SBOMs for vulnerability visibility once signing is established.
diff --git a/src/content/articles/strangler-fig-migration-complete-guide/index.mdx b/src/content/articles/strangler-fig-migration-complete-guide/index.mdx
index 445003b5..918534de 100644
--- a/src/content/articles/strangler-fig-migration-complete-guide/index.mdx
+++ b/src/content/articles/strangler-fig-migration-complete-guide/index.mdx
@@ -82,6 +82,8 @@ Strangler fig works at multiple levels of granularity, and choosing the right mi
For a monolith with a REST API, the natural unit is often a __service boundary__ — a cohesive set of endpoints that share data and business logic. Authentication is one such boundary: login, logout, password reset, and token validation form a logical group. Payments might be another. The key is that endpoints within a boundary are tightly coupled to each other but loosely coupled to the rest of the system.
+
+
Within a service boundary, you still migrate incrementally by endpoint. Start with the simplest, lowest-traffic endpoints to learn the process. For auth, that might be password reset (clear boundaries, low frequency) before tackling login (high frequency, session management complexity).
The first wave should be low-traffic, low-criticality endpoints that teach you the migration process itself — how to deploy, monitor, and roll back. You'll make mistakes, and you want those mistakes to affect the fewest users possible. Second wave takes on medium complexity. Third wave is high traffic. The final wave is core business logic and anything with deep dependencies.
@@ -1535,4 +1537,24 @@ Strangler fig migrations succeed because they trade big-bang risk for incrementa
You'll know the migration succeeded when: zero extended outages, fewer than 3 rollbacks, less than 50% schedule overrun — and the team would use the same approach again.
+
+
Auth extraction is the hardest case because auth touches everything. Keep user IDs consistent, use dual-write during migration with the legacy system as source of truth, and teach the legacy system to validate new tokens before completing the cutover. Run reconciliation jobs continuously until the auth code is deleted.
diff --git a/src/content/articles/structured-logging-correlation-ids-log-schema-design/index.mdx b/src/content/articles/structured-logging-correlation-ids-log-schema-design/index.mdx
index 1c3bf89f..891b62be 100644
--- a/src/content/articles/structured-logging-correlation-ids-log-schema-design/index.mdx
+++ b/src/content/articles/structured-logging-correlation-ids-log-schema-design/index.mdx
@@ -134,6 +134,8 @@ So how do you design a schema that actually sticks?
Every schema decision you make in month one will constrain your queries for years. Get the fundamentals right before any service ships a log.
+
+
### Schema Design Principles
Six rules that prevent the most common schema regrets:
@@ -1162,6 +1164,26 @@ Set a correlation coverage target (e.g., 99% of logs must have trace IDs) and al
Structured logging at scale requires discipline across three dimensions: schema consistency, correlation propagation, and noise management.
+
+
Adopt ECS rather than inventing a schema — it handles most use cases and enables cross-organization tooling compatibility. Implement correlation IDs at every boundary: HTTP headers for synchronous calls, message envelopes for async, and AsyncLocalStorage for automatic propagation within services. Redact sensitive data at the source, not the sink — assume logs will be accessed by anyone with read permissions.
Use collector-side processing to filter noise before storage: drop health checks and debug logs, sample high-volume events, aggregate repetitive patterns. Establish schema governance early; field naming decisions made in month one will constrain querying for years.
diff --git a/src/content/articles/symptom-based-alerting-runbooks-alert-design/index.mdx b/src/content/articles/symptom-based-alerting-runbooks-alert-design/index.mdx
index d8015724..e75c83b2 100644
--- a/src/content/articles/symptom-based-alerting-runbooks-alert-design/index.mdx
+++ b/src/content/articles/symptom-based-alerting-runbooks-alert-design/index.mdx
@@ -101,6 +101,8 @@ Each additional noisy alert reduces the attention given to __all__ alerts. Fix y
The most common alerting mistake is alerting on _causes_ rather than _symptoms_. High CPU, low disk space, elevated connection counts — these are causes. They _might_ affect users, or they might not. Request latency, error rates, failed transactions — these are symptoms. They tell you users are _actually_ experiencing problems right now.
+
+
### The Symptom-Based Philosophy
Consider three classic cause-based alerts and their symptom-based alternatives:
@@ -956,6 +958,26 @@ An alert that hasn't fired in 6 months or has < 50% actionable rate should be
Alert fatigue is a design problem, not a discipline problem. The core principle is simple: wake people up for what users experience, not for what _might_ cause problems. High CPU doesn't warrant a 2 AM page; elevated error rates do. Disk filling up gets a ticket; write failures get a page. Reserve the pager for symptoms, handle causes during business hours.
+
+
Derive thresholds from SLOs using multi-window burn rates that balance speed of detection with noise reduction. Every alert must have a runbook that answers: what's the impact, how do I diagnose, how do I fix it, when do I escalate? Route alerts appropriately: pages for user-impacting issues requiring immediate action, tickets for degradation that can wait until business hours, dashboards for awareness.
Run monthly alert reviews to prune noise, update runbooks, and adjust thresholds based on real data. The goal is not zero alerts — it's ensuring every alert that fires represents a real problem that requires human intervention, and the human receiving it has everything they need to resolve it quickly.
diff --git a/src/content/articles/synthetic-test-data-pii-anonymization-fixtures/index.mdx b/src/content/articles/synthetic-test-data-pii-anonymization-fixtures/index.mdx
index b52a773d..792b81ec 100644
--- a/src/content/articles/synthetic-test-data-pii-anonymization-fixtures/index.mdx
+++ b/src/content/articles/synthetic-test-data-pii-anonymization-fixtures/index.mdx
@@ -85,6 +85,8 @@ Under GDPR, using production personal data for testing without explicit consent
If production data is off-limits, what's the alternative? Synthetic data — generated from scratch with no connection to real customers. But "just use Faker" understates the challenge.
+
+
Generating individual fake records is the easy part. The hard part is understanding the relationships between tables well enough to generate data that won't violate constraints or produce nonsensical combinations.
### The Real Challenge: Schema Relationships
@@ -782,4 +784,24 @@ Synthetic data with this documentation passes audits easily.
Production data in non-production environments is a liability disguised as convenience. Understand your schema deeply enough to generate valid synthetic data — both explicit foreign keys and implicit relationships. When you genuinely need production patterns, anonymize through automated pipelines with deterministic transformations, but remember that highly sensitive fields should be generated fresh rather than derived from real values.
+
+
Organize fixtures by purpose, validate them against your schema in CI, and run compliance scans before every seed operation. Build generators as you build features — don't wait for a compliance incident to retrofit synthetic data onto a codebase addicted to production copies.
diff --git a/src/content/articles/terraform-module-design-defaults-versioning-interfaces/index.mdx b/src/content/articles/terraform-module-design-defaults-versioning-interfaces/index.mdx
index f5edcf6a..93d3c360 100644
--- a/src/content/articles/terraform-module-design-defaults-versioning-interfaces/index.mdx
+++ b/src/content/articles/terraform-module-design-defaults-versioning-interfaces/index.mdx
@@ -134,6 +134,8 @@ Resource renames are particularly dangerous because they cause Terraform to dest
The difference between a module that's a joy to use and one that's a constant source of frustration often comes down to input variable design. Good inputs guide users toward correct usage; bad inputs let them make mistakes that only surface at apply time — or worse, in production.
+
+
### Required vs Optional Variables
The first decision for every variable: should the consumer be forced to provide a value, or can you supply a sensible default?
@@ -1009,6 +1011,26 @@ The `examples/` directory serves double duty: it provides documentation for cons
A module's interface is a contract, and contracts create trust. When consumers can rely on your inputs behaving predictably, your outputs remaining stable, and your version numbers communicating change accurately, they'll use your modules with confidence. When they can't, they'll fork your code or write their own — and you'll have lost the leverage that shared modules provide.
+
+
The patterns here aren't complicated: default to safe values, validate inputs early, deprecate before removing, version honestly, and test what you promise. The discipline is in applying them consistently, release after release, even when you're tempted to "just make this one quick change." Every breaking change you avoid is a consumer who doesn't have to scramble. Every migration guide you write is trust you've earned.
Your module's interface is the only part most consumers will ever see. Make it a good contract to sign.
diff --git a/src/content/articles/terraform-state-locking-corruption-recovery-backend/index.mdx b/src/content/articles/terraform-state-locking-corruption-recovery-backend/index.mdx
index 575e5ad2..60b73477 100644
--- a/src/content/articles/terraform-state-locking-corruption-recovery-backend/index.mdx
+++ b/src/content/articles/terraform-state-locking-corruption-recovery-backend/index.mdx
@@ -151,6 +151,8 @@ State files contain sensitive data in plaintext: database passwords, API keys, p
When two engineers run `terraform apply` simultaneously against the same state, you get a race condition. Both read the same state, both calculate plans based on that state, and then both try to write their changes. The second write either overwrites the first (losing changes) or fails with a serial mismatch error. Neither outcome is good.
+
+
Locking prevents this by ensuring only one operation can modify state at a time. Before Terraform reads state for a plan or apply, it acquires a lock. If someone else holds the lock, Terraform waits (or fails, depending on configuration). After the operation completes — successfully or not — Terraform releases the lock.
+
The patterns are predictable: interrupted applies, concurrent modifications, manual edits, provider mismatches. Each has specific recovery procedures — force-unlock for stuck locks, version rollback for corruption, import for orphaned resources, state surgery for structural problems.
Prevention is cheaper than recovery. Use S3 versioning with DynamoDB locking. Enable CI/CD concurrency controls. Back up state to a separate region. Pin your provider versions. Never edit state files directly.
diff --git a/src/content/articles/workload-identity-federation-keyless-cloud-authentication/index.mdx b/src/content/articles/workload-identity-federation-keyless-cloud-authentication/index.mdx
index c8386505..3fb0aa88 100644
--- a/src/content/articles/workload-identity-federation-keyless-cloud-authentication/index.mdx
+++ b/src/content/articles/workload-identity-federation-keyless-cloud-authentication/index.mdx
@@ -131,6 +131,8 @@ There's nothing to leak. The OIDC token is only valid for a few minutes and is s
To really understand what's happening when you set up workload identity, you need to understand the token structure and exchange process. This isn't just academic — when federation breaks (and it will, at some point), you'll need to debug token claims, verify signatures, and trace through the exchange flow.
+
+
### OIDC Token Anatomy
The core of workload identity is a JWT — a JSON Web Token that contains claims about who's requesting access. When your GitHub Actions workflow runs, it can request an OIDC token from GitHub's identity provider. That token looks something like this when decoded:
@@ -1094,6 +1096,26 @@ The error usually looks like `AccessDenied: Not authorized to perform sts:Assume
Long-lived service account keys are one of the most common — and most preventable — security vulnerabilities in cloud infrastructure. They accumulate over time, spread to places nobody tracks, and eventually show up in a breach notification or a cryptomining bill.
+
+
Workload identity federation eliminates this risk category entirely. Instead of managing secrets that can be stolen, your workloads prove their identity through cryptographic assertions from platforms you already trust. The credentials they receive expire in an hour and can't be exfiltrated in any meaningful way.
The setup isn't trivial — you need to configure OIDC providers, establish trust relationships, map claims to permissions, and update your CI/CD workflows. But once it's done, you have zero long-lived credentials to rotate, monitor, or accidentally expose.
diff --git a/src/content/resume.json b/src/content/resume.json
index 57624cd5..8a9ba710 100644
--- a/src/content/resume.json
+++ b/src/content/resume.json
@@ -1,10 +1,15 @@
{
"education": {
"school": "Purdue University",
- "degree": "B.S. in Computational Biology and Biophysics",
+ "degree": "Coursework toward Bachelor of Science in Biophysics",
"campus": "West Lafayette, IN",
"geolocationLink": "https://goo.gl/maps/bgNJPLsubqhyUf1M6",
- "graduationDate": "May 1996"
+ "startDate": "September 1992",
+ "graduationDate": "June 1996",
+ "highlights": [
+ "Advanced coursework included thermodynamics, structural biology, and quantum physics.",
+ "Completed 110 credit hours of rigorous scientific and mathematical curriculum."
+ ]
},
"email": "kevin@webstackbuilders.com",
"firstName": "Kevin",
diff --git a/src/pages/print/[...slug].astro b/src/pages/print/[...slug].astro
index 779c8772..c88f8c64 100644
--- a/src/pages/print/[...slug].astro
+++ b/src/pages/print/[...slug].astro
@@ -20,11 +20,14 @@ import Callout from '@components/Callout/index.astro'
import CodeTabs from '@components/Code/CodeTabs/index.astro'
import Copy from '@components/Copy/index.astro'
import Diagram from '@components/Diagram/index.astro'
+import Download from '@components/CallToAction/Download/Print.astro'
import FileExplorer from '@components/FileExplorer/Print.astro'
+import Highlighter from '@components/Social/Highlighter/Print.astro'
import Icon from '@components/Icon/index.astro'
import Inset from '@components/Inset/index.astro'
import List from '@components/List/index.astro'
import ListItem from '@components/List/ListItem.astro'
+import Newsletter from '@components/CallToAction/Newsletter/Print.astro'
import Table from '@components/Table/index.astro'
import Time from '@components/Time/index.astro'
@@ -34,12 +37,15 @@ const PdfComponents = {
Callout,
Copy,
Diagram,
+ Download,
FileExplorer,
+ Highlighter,
Icon,
Image,
Inset,
List,
ListItem,
+ Newsletter,
Picture,
QrCode,
Table,
diff --git a/src/styles/code-highlighting.css b/src/styles/code-highlighting.css
index 60bf0511..804db04e 100644
--- a/src/styles/code-highlighting.css
+++ b/src/styles/code-highlighting.css
@@ -6,6 +6,51 @@
* - github-dark for dark mode
*/
+/**
+ * Map Shiki's emitted CSS variables to the active theme's palette.
+ *
+ * Shiki's css-variables theme writes per-token inline styles like
+ * `color: var(--shiki-token-keyword)` into rendered code blocks. Those
+ * variables are never referenced by a utility class, so they cannot live in
+ * Tailwind's `@theme inline` block (unused theme variables are not emitted).
+ * This plain `:root` rule survives the build and resolves against whichever
+ * `[data-theme="…"]` rule is active. It sits in the components layer so the
+ * unlayered print theme (themes/print-black-and-white.css) still overrides it
+ * for PDF output.
+ */
+:root {
+ --shiki-foreground: var(--theme-shiki-foreground);
+ --shiki-background: var(--theme-shiki-background);
+ --shiki-token-constant: var(--theme-shiki-token-constant);
+ --shiki-token-string: var(--theme-shiki-token-string);
+ --shiki-token-comment: var(--theme-shiki-token-comment);
+ --shiki-token-keyword: var(--theme-shiki-token-keyword);
+ --shiki-token-parameter: var(--theme-shiki-token-parameter);
+ --shiki-token-function: var(--theme-shiki-token-function);
+ --shiki-token-string-expression: var(--theme-shiki-token-string-expression);
+ --shiki-token-punctuation: var(--theme-shiki-token-punctuation);
+ --shiki-token-link: var(--theme-shiki-token-link);
+ --shiki-token-inserted: var(--theme-shiki-token-inserted);
+ --shiki-token-deleted: var(--theme-shiki-token-deleted);
+ --shiki-token-changed: var(--theme-shiki-token-changed);
+ --shiki-ansi-black: var(--theme-shiki-ansi-black);
+ --shiki-ansi-red: var(--theme-shiki-ansi-red);
+ --shiki-ansi-green: var(--theme-shiki-ansi-green);
+ --shiki-ansi-yellow: var(--theme-shiki-ansi-yellow);
+ --shiki-ansi-blue: var(--theme-shiki-ansi-blue);
+ --shiki-ansi-magenta: var(--theme-shiki-ansi-magenta);
+ --shiki-ansi-cyan: var(--theme-shiki-ansi-cyan);
+ --shiki-ansi-white: var(--theme-shiki-ansi-white);
+ --shiki-ansi-bright-black: var(--theme-shiki-ansi-bright-black);
+ --shiki-ansi-bright-red: var(--theme-shiki-ansi-bright-red);
+ --shiki-ansi-bright-green: var(--theme-shiki-ansi-bright-green);
+ --shiki-ansi-bright-yellow: var(--theme-shiki-ansi-bright-yellow);
+ --shiki-ansi-bright-blue: var(--theme-shiki-ansi-bright-blue);
+ --shiki-ansi-bright-magenta: var(--theme-shiki-ansi-bright-magenta);
+ --shiki-ansi-bright-cyan: var(--theme-shiki-ansi-bright-cyan);
+ --shiki-ansi-bright-white: var(--theme-shiki-ansi-bright-white);
+}
+
/**
* Shiki emits each line as … .
* We keep line numbers in a fixed gutter so wrapped lines align under the code,
diff --git a/src/styles/theme-inline.css b/src/styles/theme-inline.css
index 5c1c73f3..a8b456a2 100644
--- a/src/styles/theme-inline.css
+++ b/src/styles/theme-inline.css
@@ -242,40 +242,21 @@
--color-google-green-dark: var(--theme-color-google-green-dark); /* Light theme: #188038 */
--color-x: var(--theme-color-x); /* Light theme: #000 */
--color-x-active: var(--theme-color-x-active); /* Light theme: #1a1a1a */
-
- /**
- * =============================================================================
- * Shiki code highlighting colors
- * =============================================================================
- */
- --shiki-foreground: var(--theme-shiki-foreground);
- --shiki-background: var(--theme-shiki-background);
- --shiki-token-constant: var(--theme-shiki-token-constant);
- --shiki-token-string: var(--theme-shiki-token-string);
- --shiki-token-comment: var(--theme-shiki-token-comment);
- --shiki-token-keyword: var(--theme-shiki-token-keyword);
- --shiki-token-parameter: var(--theme-shiki-token-parameter);
- --shiki-token-function: var(--theme-shiki-token-function);
- --shiki-token-string-expression: var(--theme-shiki-token-string-expression);
- --shiki-token-punctuation: var(--theme-shiki-token-punctuation);
- --shiki-token-link: var(--theme-shiki-token-link);
- --shiki-token-inserted: var(--theme-shiki-token-inserted);
- --shiki-token-deleted: var(--theme-shiki-token-deleted);
- --shiki-token-changed: var(--theme-shiki-token-changed);
- --shiki-ansi-black: var(--theme-shiki-ansi-black);
- --shiki-ansi-red: var(--theme-shiki-ansi-red);
- --shiki-ansi-green: var(--theme-shiki-ansi-green);
- --shiki-ansi-yellow: var(--theme-shiki-ansi-yellow);
- --shiki-ansi-blue: var(--theme-shiki-ansi-blue);
- --shiki-ansi-magenta: var(--theme-shiki-ansi-magenta);
- --shiki-ansi-cyan: var(--theme-shiki-ansi-cyan);
- --shiki-ansi-white: var(--theme-shiki-ansi-white);
- --shiki-ansi-bright-black: var(--theme-shiki-ansi-bright-black);
- --shiki-ansi-bright-red: var(--theme-shiki-ansi-bright-red);
- --shiki-ansi-bright-green: var(--theme-shiki-ansi-bright-green);
- --shiki-ansi-bright-yellow: var(--theme-shiki-ansi-bright-yellow);
- --shiki-ansi-bright-blue: var(--theme-shiki-ansi-bright-blue);
- --shiki-ansi-bright-magenta: var(--theme-shiki-ansi-bright-magenta);
- --shiki-ansi-bright-cyan: var(--theme-shiki-ansi-bright-cyan);
- --shiki-ansi-bright-white: var(--theme-shiki-ansi-bright-white);
}
+
+/**
+ * =============================================================================
+ * Shiki code highlighting colors
+ * =============================================================================
+ *
+ * These mappings must live OUTSIDE `@theme inline`: Tailwind only emits theme
+ * variables that a generated utility references, and the `--shiki-token-*` /
+ * `--shiki-ansi-*` variables are consumed exclusively by Shiki's inline styles
+ * in rendered code blocks (never by a utility class). Inside `@theme inline`
+ * they were tree-shaken out of the built CSS, leaving every code token
+ * uncolored. A plain `:root` rule passes through the build untouched and
+ * resolves against the active theme's `--theme-shiki-*` values at runtime.
+ * Kept in the components layer (via code-highlighting.css) so the unlayered
+ * print theme overrides still win.
+ */
+
diff --git a/src/styles/themes/a11y.css b/src/styles/themes/a11y.css
index 1bb7c1b3..e8c11598 100644
--- a/src/styles/themes/a11y.css
+++ b/src/styles/themes/a11y.css
@@ -407,4 +407,41 @@
--a11y-theme-shiki-ansi-bright-magenta: #888;
--a11y-theme-shiki-ansi-bright-cyan: #999;
--a11y-theme-shiki-ansi-bright-white: #aaa;
+
+ /**
+ * Activate the a11y palette for Shiki: the site-wide mapping in
+ * code-highlighting.css resolves `--shiki-*` against these
+ * `--theme-shiki-*` variables at runtime.
+ */
+ --theme-shiki-foreground: var(--a11y-theme-shiki-foreground);
+ --theme-shiki-background: var(--a11y-theme-shiki-background);
+ --theme-shiki-token-constant: var(--a11y-theme-shiki-token-constant);
+ --theme-shiki-token-string: var(--a11y-theme-shiki-token-string);
+ --theme-shiki-token-comment: var(--a11y-theme-shiki-token-comment);
+ --theme-shiki-token-keyword: var(--a11y-theme-shiki-token-keyword);
+ --theme-shiki-token-parameter: var(--a11y-theme-shiki-token-parameter);
+ --theme-shiki-token-function: var(--a11y-theme-shiki-token-function);
+ --theme-shiki-token-string-expression: var(--a11y-theme-shiki-token-string-expression);
+ --theme-shiki-token-punctuation: var(--a11y-theme-shiki-token-punctuation);
+ --theme-shiki-token-link: var(--a11y-theme-shiki-token-link);
+ --theme-shiki-token-inserted: var(--a11y-theme-shiki-token-inserted);
+ --theme-shiki-token-deleted: var(--a11y-theme-shiki-token-deleted);
+ --theme-shiki-token-changed: var(--a11y-theme-shiki-token-changed);
+ --theme-shiki-ansi-black: var(--a11y-theme-shiki-ansi-black);
+ --theme-shiki-ansi-red: var(--a11y-theme-shiki-ansi-red);
+ --theme-shiki-ansi-green: var(--a11y-theme-shiki-ansi-green);
+ --theme-shiki-ansi-yellow: var(--a11y-theme-shiki-ansi-yellow);
+ --theme-shiki-ansi-blue: var(--a11y-theme-shiki-ansi-blue);
+ --theme-shiki-ansi-magenta: var(--a11y-theme-shiki-ansi-magenta);
+ --theme-shiki-ansi-cyan: var(--a11y-theme-shiki-ansi-cyan);
+ --theme-shiki-ansi-white: var(--a11y-theme-shiki-ansi-white);
+ --theme-shiki-ansi-bright-black: var(--a11y-theme-shiki-ansi-bright-black);
+ --theme-shiki-ansi-bright-red: var(--a11y-theme-shiki-ansi-bright-red);
+ --theme-shiki-ansi-bright-green: var(--a11y-theme-shiki-ansi-bright-green);
+ --theme-shiki-ansi-bright-yellow: var(--a11y-theme-shiki-ansi-bright-yellow);
+ --theme-shiki-ansi-bright-blue: var(--a11y-theme-shiki-ansi-bright-blue);
+ --theme-shiki-ansi-bright-magenta: var(--a11y-theme-shiki-ansi-bright-magenta);
+ --theme-shiki-ansi-bright-cyan: var(--a11y-theme-shiki-ansi-bright-cyan);
+ --theme-shiki-ansi-bright-white: var(--a11y-theme-shiki-ansi-bright-white);
+
}
diff --git a/vercel.json b/vercel.json
index a043f868..b6fff0d7 100644
--- a/vercel.json
+++ b/vercel.json
@@ -32,6 +32,11 @@
"source": "/deep-dive/:path*",
"destination": "/articles/:path*",
"permanent": true
+ },
+ {
+ "source": "/sitemap.xml",
+ "destination": "/sitemap-index.xml",
+ "permanent": true
}
],
"regions": ["iad1"],