blog: hero images + publish secret-zero
build-and-deploy / build (push) Failing after 11m14s

Add an optional hero field to the blog schema; render it on the post page and
as the index card thumbnail. Ship 4 verified Nano Banana hero images (shipping/
init-gating/authentik/workload) as optimized webp (64-134KB). Publish the
secret-zero post. (outbound hero pending — its file was a dup of shipping;
secret-zero hero pending generation.)
This commit is contained in:
2026-06-29 17:37:40 +10:00
parent d8a147aabe
commit 354475a453
12 changed files with 154 additions and 12 deletions
Binary file not shown.

After

Width:  |  Height:  |  Size: 106 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 135 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 93 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 64 KiB

+27 -1
View File
@@ -14,6 +14,12 @@ const { posts } = Astro.props;
posts.map((post) => (
<li class="post" data-reveal>
<a class="post__link" href={`/blog/${post.id}/`}>
{post.data.hero && (
<div class="post__thumb">
<img src={`${post.data.hero}?v=1`} alt="" width="1600" height="1073" loading="lazy" decoding="async" />
</div>
)}
<div class="post__card-body">
<div class="post__meta mono">
<time datetime={post.data.date.toISOString()}>{formatDate(post.data.date)}</time>
<span class="post__sep">·</span>
@@ -26,6 +32,7 @@ const { posts } = Astro.props;
<li class="tag">{t}</li>
))}
</ul>
</div>
</a>
</li>
))
@@ -42,13 +49,32 @@ const { posts } = Astro.props;
}
.post__link {
display: block;
padding: var(--space-5);
overflow: hidden;
border: 1px solid var(--border);
border-radius: var(--radius);
background: var(--surface);
color: inherit;
transition: border-color 0.2s var(--ease), transform 0.2s var(--ease);
}
.post__thumb {
aspect-ratio: 1600 / 1073;
overflow: hidden;
border-bottom: 1px solid var(--border);
background: var(--surface-2);
}
.post__thumb img {
display: block;
width: 100%;
height: 100%;
object-fit: cover;
transition: transform 0.3s var(--ease);
}
.post__link:hover .post__thumb img {
transform: scale(1.03);
}
.post__card-body {
padding: var(--space-5);
}
.post__link:hover {
text-decoration: none;
border-color: var(--accent-line);
+3
View File
@@ -33,6 +33,9 @@ const blog = defineCollection({
summary: z.string(),
tags: z.array(z.string()).default([]),
draft: z.boolean().default(false),
// Optional hero image — a /blog/<slug>.webp in public/. Rendered on the post + as the card thumbnail.
hero: z.string().optional(),
heroAlt: z.string().optional(),
}),
});
+3
View File
@@ -4,6 +4,9 @@ date: 2026-06-23
summary: "Putting single sign-on in front of a fleet of self-hosted apps isn't about a nicer login screen — it's about where your trust boundary lives, and how it fails."
tags: ["authentik", "oidc", "sso", "security", "kubernetes"]
draft: false
hero: "/blog/authentik-sso-2fa.webp"
heroAlt: "A single central gate ringed by a second concentric seal radiates beams of light to a circle of app-doors inside a glowing perimeter."
---
Most write-ups treat single sign-on as a feature you bolt on: stand up an identity provider, point the
@@ -3,6 +3,9 @@ title: "Init-gating GPU readiness on Kubernetes"
date: 2026-06-10
summary: "The single highest-leverage reliability fix for edge GPU workloads: never let an inference pod schedule before the GPU is actually ready."
tags: ["kubernetes", "gpu", "edge", "reliability"]
hero: "/blog/init-gating-gpu-readiness.webp"
heroAlt: "A GPU glows behind a sealed checkpoint gate while a waiting pod-orb is held in front; a readiness beacon shifts from red to green."
---
The most common way a GPU workload fails at the edge isn't the model, the driver, or the
+74
View File
@@ -0,0 +1,74 @@
---
title: "The first secret is the one you can't commit"
date: 2026-06-28
summary: "Secrets management has a bootstrap paradox: the credential that pulls every other secret can't itself live in git. Here's how a homelab fleet breaks the cycle — zero plaintext secrets in any repo, and a clean rule for which is the one exception."
tags: ["security", "gitops", "secrets", "kubernetes", "external-secrets"]
draft: false
---
![A single glowing seed-key at the root of a circuit-board tree](/blog/secret-zero.webp)
Every "we do GitOps properly" story has a chicken-and-egg problem hiding in the first paragraph, and most
posts quietly skip it.
The pitch is clean: secrets never live in git. Instead, the cluster runs the [External Secrets
Operator](https://external-secrets.io) (ESO), which reads from a real secret store — here, a self-hosted
[Infisical](https://infisical.com) — and materialises a Kubernetes `Secret` for each app. Your repo only ever
contains an `ExternalSecret` manifest: a **pointer** ("give `immich` the value at key `IMMICH_DB_PASSWORD`"),
never a value. Beautiful. Auditable. Diff-able.
Except — ESO has to authenticate to Infisical somehow. That's a credential. Where does *it* live?
## Secret zero
You can't store the bootstrap credential in git (that's the whole point), and you can't fetch it from the
secret store (you need it *to reach* the secret store). This is **secret zero**: the one credential that the
entire chain hangs off, that has to be injected from outside the GitOps loop.
So you treat it as exactly that — special, minimal, and out-of-band:
- It's a **machine identity** scoped to *read-only*, and scoped per cluster. cave's ESO can't read alfred's
secrets and vice-versa. A leak is blast-radius-limited to one cluster's read path.
- It's applied **once, by hand** (or by a sealed bootstrap step), never committed. Everything downstream of it
is declarative.
- After it lands, a single `ClusterSecretStore` object — which *is* in git, because it's just a pointer to the
store plus a reference to secret zero — wires the whole cluster up.
From there the dam breaks in the good way: every app's `ExternalSecret` resolves through that store, ESO keeps
the `Secret` in sync, and your repo stays a map of *names*, not values.
<!-- DIAGRAM: secret-zero bootstrap chain — out-of-band identity → ClusterSecretStore → ExternalSecrets → app Secrets -->
![Secret-zero bootstrap chain](/diagrams/secret-zero.svg)
## The discipline around the one exception
One out-of-band credential is fine. The trap is letting it sprawl — or fumbling its rotation. Two rules earn
their keep:
**Per-cluster, least-privilege identities.** It's tempting to mint one powerful identity and reuse it
everywhere. Don't. A read-only identity per cluster means the bootstrap secret can *read* and nothing else, in
*one* place. The write path (provisioning new secrets) is a separate, rarely-used identity that doesn't sit on
every node.
**Never revoke a shared identity until every consumer has moved off it.** This sounds obvious and is the
single easiest way to take the fleet down. When you split a shared identity into per-cluster ones, the old one
stays valid until you've *verified* each cluster is happily authenticating on its own — then, and only then,
you revoke. Revoke-first-verify-later turns a tidy-up into an outage.
## What still belongs in git (encrypted)
A purist would stop here, but reality has a few things that genuinely need to live *in* the repo — a value a
bootstrap step reads before ESO is even running. For those, the answer isn't "commit it in plaintext and feel
bad," it's **SOPS + age**: the value is encrypted in git, decryptable only by a key that lives on the
operators' machines (and the cluster), never in the repo. Same principle as secret zero — the *decryption* key
is the out-of-band thing — applied to the handful of values that can't wait for the operator to spin up.
## The shape of it
The win isn't any one tool. It's the shape: **exactly one** credential lives outside GitOps, it's read-only
and per-cluster, and it's the seed the whole tree grows from. Everything else is a pointer you can show
your worst enemy. When someone asks "where are your secrets?", the honest answer is "in the store — the repo
just knows their *names*," and that one sentence is the whole security model.
*This runs across a five-cluster Talos homelab; the ESO + Infisical wiring, the per-cluster read-only
identities, and the revoke-order discipline are the load-bearing pieces.*
+3
View File
@@ -3,6 +3,9 @@ title: "Shipping this site: GitOps from a homelab to the public internet"
date: 2026-06-15
summary: "How this portfolio is built and served — Astro to a container image, a self-hosted Gitea registry, ArgoCD, and a Cloudflare Tunnel — with security as acceptance criteria, not polish."
tags: ["gitops", "astro", "homelab", "security"]
hero: "/blog/shipping-this-site.webp"
heroAlt: "A glowing data container travels a luminous rail from a small server rack toward a softly glowing globe of Earth, wrapped by a reconcile loop."
---
This site is a static Astro build, but how it gets to you is the interesting part. It's
@@ -4,6 +4,9 @@ date: 2026-06-23
summary: "Least privilege for Kubernetes workloads doesn't start with an RBAC role — it starts with revoking the API token every pod silently carries, then layering identity, non-root, and Pod Security on top."
tags: ["kubernetes", "security", "least-privilege", "rbac", "service-accounts"]
draft: false
hero: "/blog/workload-least-privilege.webp"
heroAlt: "Rows of glowing cube-pods each clutch a keycard, facing a sealed vault door none of them open; one keycard dissolves into particles, revoked."
---
Open a shell in almost any Kubernetes pod and look in `/var/run/secrets/kubernetes.io`. There's a token
+27
View File
@@ -33,6 +33,19 @@ const { Content } = await render(entry);
</ul>
</header>
{entry.data.hero && (
<figure class="post__hero" data-reveal>
<img
src={`${entry.data.hero}?v=1`}
alt={entry.data.heroAlt ?? entry.data.title}
width="1600"
height="1073"
loading="eager"
decoding="async"
/>
</figure>
)}
<div class="prose post__body" data-reveal>
<Content />
</div>
@@ -45,6 +58,20 @@ const { Content } = await render(entry);
max-width: 48rem;
padding-block: var(--space-7);
}
.post__hero {
margin: var(--space-6) 0 var(--space-7);
border-radius: var(--radius);
overflow: hidden;
border: 1px solid var(--border);
box-shadow: var(--shadow), 0 0 30px var(--accent-glow);
background: var(--surface);
}
.post__hero img {
display: block;
width: 100%;
height: auto;
aspect-ratio: 1600 / 1073;
}
.post__back {
display: inline-block;
font-size: var(--step--1);