diff --git a/public/blog/authentik-sso-2fa.webp b/public/blog/authentik-sso-2fa.webp new file mode 100644 index 0000000..05c4948 Binary files /dev/null and b/public/blog/authentik-sso-2fa.webp differ diff --git a/public/blog/init-gating-gpu-readiness.webp b/public/blog/init-gating-gpu-readiness.webp new file mode 100644 index 0000000..8481ee2 Binary files /dev/null and b/public/blog/init-gating-gpu-readiness.webp differ diff --git a/public/blog/shipping-this-site.webp b/public/blog/shipping-this-site.webp new file mode 100644 index 0000000..21abd87 Binary files /dev/null and b/public/blog/shipping-this-site.webp differ diff --git a/public/blog/workload-least-privilege.webp b/public/blog/workload-least-privilege.webp new file mode 100644 index 0000000..7be538d Binary files /dev/null and b/public/blog/workload-least-privilege.webp differ diff --git a/src/components/PostList.astro b/src/components/PostList.astro index 2461a1e..6868396 100644 --- a/src/components/PostList.astro +++ b/src/components/PostList.astro @@ -14,18 +14,25 @@ const { posts } = Astro.props; posts.map((post) => (
  • -
    - - · - {readingTime(post.body)} + {post.data.hero && ( +
    + +
    + )} +
    + +

    {post.data.title}

    +

    {post.data.summary}

    +
    -

    {post.data.title}

    -

    {post.data.summary}

    -
  • )) @@ -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); diff --git a/src/content.config.ts b/src/content.config.ts index fcafb28..12ab186 100644 --- a/src/content.config.ts +++ b/src/content.config.ts @@ -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/.webp in public/. Rendered on the post + as the card thumbnail. + hero: z.string().optional(), + heroAlt: z.string().optional(), }), }); diff --git a/src/content/blog/authentik-sso-2fa.md b/src/content/blog/authentik-sso-2fa.md index 938c90a..1fbb9e2 100644 --- a/src/content/blog/authentik-sso-2fa.md +++ b/src/content/blog/authentik-sso-2fa.md @@ -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 diff --git a/src/content/blog/init-gating-gpu-readiness.md b/src/content/blog/init-gating-gpu-readiness.md index b6e7bc4..5641819 100644 --- a/src/content/blog/init-gating-gpu-readiness.md +++ b/src/content/blog/init-gating-gpu-readiness.md @@ -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 diff --git a/src/content/blog/secret-zero.md b/src/content/blog/secret-zero.md new file mode 100644 index 0000000..5a69590 --- /dev/null +++ b/src/content/blog/secret-zero.md @@ -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. + + +![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.* diff --git a/src/content/blog/shipping-this-site.md b/src/content/blog/shipping-this-site.md index 1724445..5cea651 100644 --- a/src/content/blog/shipping-this-site.md +++ b/src/content/blog/shipping-this-site.md @@ -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 diff --git a/src/content/blog/workload-least-privilege.md b/src/content/blog/workload-least-privilege.md index 3c2a394..825b897 100644 --- a/src/content/blog/workload-least-privilege.md +++ b/src/content/blog/workload-least-privilege.md @@ -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 diff --git a/src/pages/blog/[slug].astro b/src/pages/blog/[slug].astro index 2fc85c3..704d04b 100644 --- a/src/pages/blog/[slug].astro +++ b/src/pages/blog/[slug].astro @@ -33,6 +33,19 @@ const { Content } = await render(entry); + {entry.data.hero && ( +
    + {entry.data.heroAlt +
    + )} +
    @@ -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);