<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom"><channel><title>Knative on houdeshell.dev</title><link>https://houdeshell.dev/tags/knative/</link><description>Recent content in Knative on houdeshell.dev</description><generator>Hugo -- gohugo.io</generator><language>en-us</language><copyright>© CRH</copyright><lastBuildDate>Sat, 02 May 2026 18:00:00 -0400</lastBuildDate><atom:link href="https://houdeshell.dev/tags/knative/index.xml" rel="self" type="application/rss+xml"/><item><title>Software that I &lt;img class='title-icon' src='https://houdeshell.dev/static/images/lego-heart.svg' alt='heart' /></title><link>https://houdeshell.dev/software/</link><pubDate>Mon, 01 Jun 2020 00:00:00 +0000</pubDate><guid>https://houdeshell.dev/software/</guid><description>&lt;p>I bounce between Mac, Windows, and Linux daily. Honestly, the tools matter more than the OS at this point. Great software is great software, and I&amp;rsquo;ve been lucky enough to build a career on top of things other people built well.&lt;/p>
&lt;p>This is the stuff I actually reach for. Not a &amp;ldquo;best of&amp;rdquo; list, not sponsored, not comprehensive. Just software that makes me better at what I do. Or at least makes the work more fun.&lt;/p>
&lt;style>
.sw-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
gap: 14px;
margin: 1.5em 0;
}
.sw-card {
background: var(--bg-dark);
border: 1px solid var(--bg-dark-border);
border-radius: 6px;
padding: 1.1em 1.2em;
transition: background 0.15s ease, border-color 0.15s ease;
}
.sw-card:hover {
background: var(--bg-dark-elevated);
border-color: var(--bg-dark-accent);
}
.sw-card h3 {
margin: 0 0 0.25em !important;
font-size: 1em !important;
font-weight: 650 !important;
border: none !important;
padding: 0 !important;
color: var(--bg-dark-text) !important;
text-decoration: none !important;
}
.sw-card h3::before {
content: none !important;
}
.sw-card h3 a {
color: var(--bg-dark-text);
text-decoration: none;
border-bottom: 1px dashed transparent;
transition: color 0.15s ease, border-color 0.15s ease;
}
.sw-card:hover h3 a {
color: var(--bg-dark-accent);
border-bottom-color: var(--bg-dark-accent-line);
}
.sw-card p {
margin: 0;
font-size: 0.85em;
color: var(--bg-dark-text-secondary);
line-height: 1.55;
}
.sw-card p a {
color: var(--bg-dark-accent);
text-decoration: none;
border-bottom: 1px dashed var(--bg-dark-accent-line);
}
.sw-card p a:hover {
color: var(--bg-dark-accent-hover);
border-bottom-color: var(--bg-dark-accent);
}
.sw-card code {
background: rgba(255, 255, 255, 0.06) !important;
color: var(--bg-dark-text) !important;
border-color: rgba(255, 255, 255, 0.10) !important;
}
.sw-card .sw-tag {
display: inline-block;
font-family: var(--font-mono);
font-size: 0.68em;
font-weight: 600;
letter-spacing: 0.06em;
text-transform: uppercase;
color: var(--bg-dark-accent);
background: var(--bg-dark-accent-soft);
border: 1px solid var(--bg-dark-accent-line);
border-radius: 3px;
padding: 1px 7px;
margin-bottom: 0.6em;
}
&lt;/style>
&lt;h2 id="software-development">Software Development&lt;/h2>
&lt;div class="sw-grid">
&lt;div class="sw-card">
&lt;span class="sw-tag">IDE&lt;/span>
&lt;h3 id="jetbrains-rider">&lt;a href="https://www.jetbrains.com/rider/"target="_blank" rel="noopener noreferrer">JetBrains Rider&lt;/a>&lt;/h3>
&lt;p>Cross-platform .NET IDE. Fast, smart, and doesn&amp;rsquo;t need Visual Studio&amp;rsquo;s weight to get the job done.&lt;/p>
&lt;/div>
&lt;div class="sw-card">
&lt;span class="sw-tag">IDE&lt;/span>
&lt;h3 id="visual-studio">&lt;a href="https://visualstudio.microsoft.com/"target="_blank" rel="noopener noreferrer">Visual Studio&lt;/a>&lt;/h3>
&lt;p>The OG. Heavy, but when you need the full .NET debugging experience, nothing else comes close.&lt;/p>
&lt;/div>
&lt;div class="sw-card">
&lt;span class="sw-tag">editor&lt;/span>
&lt;h3 id="vs-code">&lt;a href="https://code.visualstudio.com/"target="_blank" rel="noopener noreferrer">VS Code&lt;/a>&lt;/h3>
&lt;p>Extension ecosystem is unmatched. Somehow Electron done right.&lt;/p>
&lt;/div>
&lt;div class="sw-card">
&lt;span class="sw-tag">editor&lt;/span>
&lt;h3 id="neovim">&lt;a href="https://neovim.io/"target="_blank" rel="noopener noreferrer">Neovim&lt;/a>&lt;/h3>
&lt;p>Vim reborn. Lua config, LSP native, and a plugin ecosystem that won&amp;rsquo;t quit. &lt;code>:wq&lt;/code> is a lifestyle.&lt;/p>
&lt;/div>
&lt;div class="sw-card">
&lt;span class="sw-tag">version control&lt;/span>
&lt;h3 id="git">&lt;a href="https://git-scm.com/"target="_blank" rel="noopener noreferrer">Git&lt;/a>&lt;/h3>
&lt;p>The version control system that won. Love it or hate it, you can&amp;rsquo;t ship without it.&lt;/p>
&lt;/div>
&lt;div class="sw-card">
&lt;span class="sw-tag">version control&lt;/span>
&lt;h3 id="lazygit">&lt;a href="https://github.com/jesseduffield/lazygit"target="_blank" rel="noopener noreferrer">lazygit&lt;/a>&lt;/h3>
&lt;p>Terminal UI for git that makes interactive rebases feel like cheating.&lt;/p>
&lt;/div>
&lt;div class="sw-card">
&lt;span class="sw-tag">JSON&lt;/span>
&lt;h3 id="jq">&lt;a href="https://jqlang.github.io/jq/"target="_blank" rel="noopener noreferrer">jq&lt;/a>&lt;/h3>
&lt;p>&lt;code>sed&lt;/code> for JSON. Once you learn the syntax, you&amp;rsquo;ll pipe everything through it.&lt;/p>
&lt;/div>
&lt;div class="sw-card">
&lt;span class="sw-tag">database&lt;/span>
&lt;h3 id="jetbrains-datagrip">&lt;a href="https://www.jetbrains.com/datagrip/"target="_blank" rel="noopener noreferrer">JetBrains DataGrip&lt;/a>&lt;/h3>
&lt;p>SQL IDE that actually understands your schema. Autocomplete that works across joins.&lt;/p>
&lt;/div>
&lt;div class="sw-card">
&lt;span class="sw-tag">database&lt;/span>
&lt;h3 id="dbeaver">&lt;a href="https://dbeaver.io/"target="_blank" rel="noopener noreferrer">DBeaver&lt;/a>&lt;/h3>
&lt;p>Universal database tool that actually works. Connect to anything, query everything.&lt;/p>
&lt;/div>
&lt;div class="sw-card">
&lt;span class="sw-tag">database&lt;/span>
&lt;h3 id="ssms">&lt;a href="https://learn.microsoft.com/en-us/sql/ssms/"target="_blank" rel="noopener noreferrer">SSMS&lt;/a>&lt;/h3>
&lt;p>Microsoft SQL Management Studio. If you&amp;rsquo;re in SQL Server land, you already know.&lt;/p>
&lt;/div>
&lt;div class="sw-card">
&lt;span class="sw-tag">database&lt;/span>
&lt;h3 id="postgresql">&lt;a href="https://www.postgresql.org/"target="_blank" rel="noopener noreferrer">PostgreSQL&lt;/a>&lt;/h3>
&lt;p>The database that keeps getting better. Extensions, JSON support, and rock-solid reliability.&lt;/p>
&lt;/div>
&lt;div class="sw-card">
&lt;span class="sw-tag">containers&lt;/span>
&lt;h3 id="docker">&lt;a href="https://www.docker.com/"target="_blank" rel="noopener noreferrer">Docker&lt;/a>&lt;/h3>
&lt;p>&amp;ldquo;Works on my machine&amp;rdquo; became &amp;ldquo;works on every machine.&amp;rdquo; Changed how we ship software.&lt;/p>
&lt;/div>
&lt;/div>
&lt;h2 id="ai">AI&lt;/h2>
&lt;div class="sw-grid">
&lt;div class="sw-card">
&lt;span class="sw-tag">agent&lt;/span>
&lt;h3 id="claude-code">&lt;a href="https://docs.anthropic.com/en/docs/claude-code"target="_blank" rel="noopener noreferrer">Claude Code&lt;/a>&lt;/h3>
&lt;p>AI pair programmer in your terminal. It&amp;rsquo;s writing this page right now.&lt;/p>
&lt;/div>
&lt;div class="sw-card">
&lt;span class="sw-tag">orchestration&lt;/span>
&lt;h3 id="jetbrains-air">&lt;a href="https://air.dev/"target="_blank" rel="noopener noreferrer">JetBrains Air&lt;/a>&lt;/h3>
&lt;p>Agent orchestration in IDE form. Run Claude, Codex, Gemini CLI, and Junie at the same time. Each one sandboxed, all of them working while you do something else.&lt;/p>
&lt;/div>
&lt;div class="sw-card">
&lt;span class="sw-tag">agent&lt;/span>
&lt;h3 id="codex">&lt;a href="https://github.com/openai/codex"target="_blank" rel="noopener noreferrer">Codex&lt;/a>&lt;/h3>
&lt;p>OpenAI&amp;rsquo;s open-source coding agent. Terminal-native, sandboxed by default, and you can swap the model behind it whenever.&lt;/p>
&lt;/div>
&lt;div class="sw-card">
&lt;span class="sw-tag">platform&lt;/span>
&lt;h3 id="azure-ai-foundry">&lt;a href="https://azure.microsoft.com/en-us/products/ai-foundry"target="_blank" rel="noopener noreferrer">Azure AI Foundry&lt;/a>&lt;/h3>
&lt;p>Microsoft&amp;rsquo;s AI app stack. Build agents, point them at your data, ship them on Azure. The enterprise wrapper for everything you&amp;rsquo;d otherwise glue together yourself.&lt;/p>
&lt;/div>
&lt;/div>
&lt;h2 id="infrastructure">Infrastructure&lt;/h2>
&lt;div class="sw-grid">
&lt;div class="sw-card">
&lt;span class="sw-tag">orchestration&lt;/span>
&lt;h3 id="kubernetes">&lt;a href="https://kubernetes.io/"target="_blank" rel="noopener noreferrer">Kubernetes&lt;/a>&lt;/h3>
&lt;p>Container orchestration that makes you mass produce YAML for a living.&lt;/p>
&lt;/div>
&lt;div class="sw-card">
&lt;span class="sw-tag">orchestration&lt;/span>
&lt;h3 id="k3s">&lt;a href="https://k3s.io/"target="_blank" rel="noopener noreferrer">K3s&lt;/a>&lt;/h3>
&lt;p>All of Kubernetes in a single binary. Perfect for homelab, edge, and production deployments.&lt;/p>
&lt;/div>
&lt;div class="sw-card">
&lt;span class="sw-tag">IaC&lt;/span>
&lt;h3 id="terraform--opentofu">&lt;a href="https://www.terraform.io/"target="_blank" rel="noopener noreferrer">Terraform&lt;/a> / &lt;a href="https://opentofu.org/"target="_blank" rel="noopener noreferrer">OpenTofu&lt;/a>&lt;/h3>
&lt;p>Infrastructure as code. OpenTofu if you prefer the open-source fork without the license drama.&lt;/p>
&lt;/div>
&lt;div class="sw-card">
&lt;span class="sw-tag">networking&lt;/span>
&lt;h3 id="tailscale">&lt;a href="https://tailscale.com/"target="_blank" rel="noopener noreferrer">Tailscale&lt;/a>&lt;/h3>
&lt;p>WireGuard-based mesh VPN that just works. Connect everything without opening a single port.&lt;/p>
&lt;/div>
&lt;div class="sw-card">
&lt;span class="sw-tag">observability&lt;/span>
&lt;h3 id="grafana">&lt;a href="https://grafana.com/"target="_blank" rel="noopener noreferrer">Grafana&lt;/a>&lt;/h3>
&lt;p>Dashboards for everything. Pairs with Prometheus, Loki, and Tempo for the whole observability stack.&lt;/p>
&lt;/div>
&lt;div class="sw-card">
&lt;span class="sw-tag">Kubernetes&lt;/span>
&lt;h3 id="k9s">&lt;a href="https://k9scli.io/"target="_blank" rel="noopener noreferrer">k9s&lt;/a>&lt;/h3>
&lt;p>Terminal UI for Kubernetes. Makes cluster management feel like a video game. Way faster than raw &lt;code>kubectl&lt;/code>.&lt;/p>
&lt;/div>
&lt;div class="sw-card">
&lt;span class="sw-tag">Kubernetes&lt;/span>
&lt;h3 id="lens">&lt;a href="https://k8slens.dev/"target="_blank" rel="noopener noreferrer">Lens&lt;/a>&lt;/h3>
&lt;p>The Kubernetes IDE. When you want to point and click your way through a cluster without shame.&lt;/p>
&lt;/div>
&lt;div class="sw-card">
&lt;span class="sw-tag">it's complicated&lt;/span>
&lt;h3 id="cloudflare">&lt;a href="https://www.cloudflare.com/"target="_blank" rel="noopener noreferrer">Cloudflare&lt;/a>&lt;/h3>
&lt;p>You love them. You hate them. Your DNS is already there. Their edge network is everywhere, their pricing is unbeatable, and their product sprawl is terrifying. Stockholm syndrome as a service.&lt;/p>
&lt;/div>
&lt;/div>
&lt;h2 id="shell--terminal">Shell &amp;amp; Terminal&lt;/h2>
&lt;div class="sw-grid">
&lt;div class="sw-card">
&lt;span class="sw-tag">shell&lt;/span>
&lt;h3 id="bash">&lt;a href="https://www.gnu.org/software/bash/"target="_blank" rel="noopener noreferrer">Bash&lt;/a>&lt;/h3>
&lt;p>The shell that&amp;rsquo;s been there since before you were born. Simple, portable, everywhere.&lt;/p>
&lt;/div>
&lt;div class="sw-card">
&lt;span class="sw-tag">shell&lt;/span>
&lt;h3 id="zsh">&lt;a href="https://www.zsh.org/"target="_blank" rel="noopener noreferrer">Zsh&lt;/a>&lt;/h3>
&lt;p>Bash&amp;rsquo;s cooler sibling. Tab completion, globbing, and plugin support that actually makes the terminal enjoyable.&lt;/p>
&lt;/div>
&lt;div class="sw-card">
&lt;span class="sw-tag">prompt&lt;/span>
&lt;h3 id="starship">&lt;a href="https://github.com/starship/starship"target="_blank" rel="noopener noreferrer">Starship&lt;/a>&lt;/h3>
&lt;p>Cross-shell prompt written in Rust. Fast, pretty, and infinitely configurable.&lt;/p>
&lt;/div>
&lt;div class="sw-card">
&lt;span class="sw-tag">multiplexer&lt;/span>
&lt;h3 id="tmux">&lt;a href="https://github.com/tmux/tmux"target="_blank" rel="noopener noreferrer">tmux&lt;/a>&lt;/h3>
&lt;p>Terminal multiplexer. SSH into a box, detach, come back tomorrow. Your session is still there.&lt;/p>
&lt;/div>
&lt;div class="sw-card">
&lt;span class="sw-tag">coreutils&lt;/span>
&lt;h3 id="eza">&lt;a href="https://github.com/eza-community/eza"target="_blank" rel="noopener noreferrer">eza&lt;/a>&lt;/h3>
&lt;p>Modern replacement for &lt;code>ls&lt;/code>. Colors, git status, and tree view out of the box. Written in Rust, obviously.&lt;/p>
&lt;/div>
&lt;/div>
&lt;h2 id="utilities--apps">Utilities / Apps&lt;/h2>
&lt;div class="sw-grid">
&lt;div class="sw-card">
&lt;span class="sw-tag">macOS&lt;/span>
&lt;h3 id="alttab">&lt;a href="https://alt-tab-macos.netlify.app/"target="_blank" rel="noopener noreferrer">AltTab&lt;/a>&lt;/h3>
&lt;p>Why does macOS think a minimized window is an inactive window and not let you switch to it? This fixes that.&lt;/p>
&lt;/div>
&lt;/div></description></item><item><title>About</title><link>https://houdeshell.dev/about/</link><pubDate>Sat, 11 Apr 2020 00:00:00 +0000</pubDate><guid>https://houdeshell.dev/about/</guid><description>&lt;div class="about-intro">
&lt;p class="about-tagline">// bit herder. pointer wrecker. yaml apologist.&lt;/p>
&lt;/div>
&lt;p>I&amp;rsquo;m a bit herder, a pointer wrecker, and HTTP is my best friend. &lt;code>0xA3&lt;/code> &amp;amp; &lt;code>0x7C&lt;/code> are also my friends, but not &lt;code>0x08&lt;/code>.&lt;/p>
&lt;p>I spend my days somewhere between writing code and making sure other people can write better code. I love distributed systems, low-level embedded devices, and anything that has a &lt;code>content-type&lt;/code>. I also have an unhealthy relationship with YAML, but that&amp;rsquo;s a Kubernetes problem.&lt;/p>
&lt;h2 id="the-day-job">The day job&lt;/h2>
&lt;p>During the day, I lead engineering and operations at &lt;a href="https://www.energycap.com"target="_blank" rel="noopener noreferrer">EnergyCAP&lt;/a>, a SaaS platform that helps organizations track utility spend, energy consumption, and sustainability goals across massive building portfolios. We&amp;rsquo;re talking &lt;strong>$100B+ in utility bill spend&lt;/strong> tracked across &lt;strong>350,000+ sites&lt;/strong>. Government, education, healthcare, commercial real estate. If it has a meter, we probably manage it.&lt;/p>
&lt;p>I started here as a developer in 2009 and have been building ever since: through senior roles, into leadership, and eventually into the VP seat where I get to shape both the tech and the teams behind it. Some days that means architecture decisions. Most days it means removing blockers so smart people can do their best work. And yes, sometimes it&amp;rsquo;s just meetings about meetings.&lt;/p>
&lt;p>My philosophy is simple: &lt;strong>technology should serve people&lt;/strong>. Build teams where people feel safe to be wrong, give them the tools to be great, and get out of the way.&lt;/p>
&lt;h3 id="things-i-geek-out-about">Things I geek out about&lt;/h3>
&lt;p>Distributed systems, container orchestration, database performance tuning, developer tooling, process dumps, leadership that doesn&amp;rsquo;t suck, making engineers&amp;rsquo; lives better, and the eternal quest for a perfect terminal setup.&lt;/p>
&lt;h2 id="i-also-talk-at-things">I also talk at things&lt;/h2>
&lt;p>I like getting on stage and nerding out about the things I&amp;rsquo;ve learned the hard way. My talks tend to fall into a few buckets, and yes, I have strong opinions about all of them.&lt;/p>
&lt;h3 id="ai--the-developer-experience">AI &amp;amp; the developer experience&lt;/h3>
&lt;p>The AI wave hit and I leaned in. I&amp;rsquo;ve talked about using LLMs as debugging partners, building RAG pipelines that actually know your business context, and the ethical lines we should be drawing as we hand more work to machines.&lt;/p>
&lt;ul class="talk-list">
&lt;li>My Rubber Duck is a Large Language Model&lt;/li>
&lt;li>Unleashing the Power of the AI Wizards: Retrieval-Augmented Generation Spells&lt;/li>
&lt;/ul>
&lt;h3 id="kubernetes--cloud-native">Kubernetes &amp;amp; cloud native&lt;/h3>
&lt;p>I was late to the Kubernetes party, and I&amp;rsquo;ll tell you all about it. From lightweight K3s workshops to navigating the 900+ services in the CNCF landscape, to making the case that cloud-native principles work even when you&amp;rsquo;re not in the cloud.&lt;/p>
&lt;ul class="talk-list">
&lt;li>Kubernetes Chronicles: Late to the Party, Big on Adventure!&lt;/li>
&lt;li>K3s: Half the Size, Twice as Awesome (workshop)&lt;/li>
&lt;li>Orchestrating Machine Learning Workloads with Kubernetes&lt;/li>
&lt;li>Exploring the Cloud Native Landscape&lt;/li>
&lt;li>Cloud Native is Only for the Cloud, Right?&lt;/li>
&lt;li>Nomad: Orchestration Doesn't Start with a K&lt;/li>
&lt;/ul>
&lt;h3 id="performance--databases">Performance &amp;amp; databases&lt;/h3>
&lt;p>I&amp;rsquo;ve spent an unreasonable amount of time staring at query plans. These talks cover squeezing performance out of SQL Server, using DMVs like cheat codes, and that one time we took a 10-hour process down to 10 minutes.&lt;/p>
&lt;ul class="talk-list">
&lt;li>ReArchitecting Data: 10 Hours to 10 Minutes&lt;/li>
&lt;li>Mastering SQL Server Performance Optimization (workshop)&lt;/li>
&lt;li>SQL Server DMVs That Give Me Superpowers&lt;/li>
&lt;li>Achieving Continuous High Performance with Query Store&lt;/li>
&lt;li>Practical High Performance: C# Edition&lt;/li>
&lt;li>Intrinsics in .NET: Start Somewhere&lt;/li>
&lt;/ul>
&lt;h3 id="production-war-stories--leadership">Production war stories &amp;amp; leadership&lt;/h3>
&lt;p>Production breaks. Systems fail. The interesting part is what you do next. I also talk about the human side: what I&amp;rsquo;ve learned (and gotten wrong) leading engineering teams.&lt;/p>
&lt;ul class="talk-list">
&lt;li>Bug Squashing with Process Dumps&lt;/li>
&lt;li>Recovery by Design: A Postmortem Adventure&lt;/li>
&lt;li>Joining the Cloud: Our Journey&lt;/li>
&lt;li>TIL as a CTO&lt;/li>
&lt;/ul>
&lt;h3 id="the-wildcard">The wildcard&lt;/h3>
&lt;p>Sometimes you just want to build something fun with your hands.&lt;/p>
&lt;ul class="talk-list">
&lt;li>Paper Circuits: Origami for a New Generation&lt;/li>
&lt;/ul>
&lt;h2 id="lets-talk">Let&amp;rsquo;s talk&lt;/h2>
&lt;p>I drink a bit too much coffee while rambling on about technology. If you&amp;rsquo;re willing to have a conversation, I&amp;rsquo;m ready to buy you a cup.&lt;/p>
&lt;dl class="contact-list">
&lt;dt>email&lt;/dt>&lt;dd>&lt;a href="mailto:chris@houdeshell.dev">chris@houdeshell.dev&lt;/a>&lt;/dd>
&lt;dt>github&lt;/dt>&lt;dd>&lt;a href="https://github.com/choudeshell">choudeshell&lt;/a>&lt;/dd>
&lt;dt>linkedin&lt;/dt>&lt;dd>&lt;a href="https://www.linkedin.com/in/choudeshell/">choudeshell&lt;/a>&lt;/dd>
&lt;dt>twitter&lt;/dt>&lt;dd>&lt;a href="https://twitter.com/choudeshell">@choudeshell&lt;/a>&lt;/dd>
&lt;/dl></description></item><item><title>Now</title><link>https://houdeshell.dev/now/</link><pubDate>Sat, 02 May 2026 00:00:00 +0000</pubDate><guid>https://houdeshell.dev/now/</guid><description>&lt;header class="now-header">
&lt;p class="now-prompt">&lt;span class="now-prompt-symbol">~/$&lt;/span> cat now.md&lt;/p>
&lt;p class="now-meta">last updated: &lt;time datetime="2026-05-02">May 2, 2026&lt;/time>&lt;/p>
&lt;/header>
&lt;section class="now-block">
&lt;h2 id="focus">Focus&lt;/h2>
&lt;ul>
&lt;li>&lt;strong>Production AI agents at &lt;code>/\$DAYJOB/&lt;/code>.&lt;/strong> Rolling out internal agent infrastructure with real customer data behind it. Compliance edges, eval suites, on-call playbooks. The fun stuff and the boring stuff in equal measure.&lt;/li>
&lt;li>&lt;strong>AI document extraction.&lt;/strong> Pulling structured data out of utility bills and the kind of PDFs that look like they were faxed in 2003. Two-phase validation: deterministic checks first (totals match, dates parse, units make sense), then a nondeterministic pass with a model when the deterministic layer can&amp;rsquo;t reach a verdict. The hybrid catches more than either side alone.&lt;/li>
&lt;li>&lt;strong>The Flue stack on Kubernetes.&lt;/strong> Self-hosted serverless for agents. Three posts in the can, more in the queue.&lt;/li>
&lt;li>&lt;strong>Cost-aware Kubernetes.&lt;/strong> Watching cluster spend like a hawk while AI workloads scale up unpredictably. There&amp;rsquo;s a talk hiding in here somewhere.&lt;/li>
&lt;/ul>
&lt;/section>
&lt;section class="now-block">
&lt;h2 id="stack-this-week">Stack This Week&lt;/h2>
&lt;ul>
&lt;li>&lt;strong>TypeScript 7 beta.&lt;/strong> The Go rewrite is real. Compile feels like Go.&lt;/li>
&lt;li>&lt;strong>Flue + Knative.&lt;/strong> Self-hosted agent harness on K8s.&lt;/li>
&lt;/ul>
&lt;/section>
&lt;section class="now-block">
&lt;h2 id="reading">Reading&lt;/h2>
&lt;ul>
&lt;li>More LLM eval papers than I expected to.&lt;/li>
&lt;li>Internal architecture decision records. More than I want to admit.&lt;/li>
&lt;/ul>
&lt;/section>
&lt;section class="now-block">
&lt;h2 id="thinking">Thinking&lt;/h2>
&lt;ul>
&lt;li>An internal &lt;code>/\$DAYJOB/&lt;/code> session on the agent rollout.&lt;/li>
&lt;/ul>
&lt;/section></description></item><item><title>Flue, Part 3: Self-Hosting Agents with Knative</title><link>https://houdeshell.dev/post/2026-05-02_flue-knative-kubernetes/</link><pubDate>Sat, 02 May 2026 18:00:00 -0400</pubDate><guid>https://houdeshell.dev/post/2026-05-02_flue-knative-kubernetes/</guid><description>&lt;p>&lt;a href="https://houdeshell.dev/post/2026-05-02_flue-mastra-typescript-agents/">Part 1&lt;/a> was the framework pitch. &lt;a href="https://houdeshell.dev/post/2026-05-09_flue-jira-triage/">Part 2&lt;/a> was the agent I wanted to build. This one is for the people who can&amp;rsquo;t (or won&amp;rsquo;t) ship that agent to Cloudflare Workers.&lt;/p>
&lt;p>The reasons vary. Compliance says all customer data stays in our VPC. Security says no third-party serverless on the data path. Procurement won&amp;rsquo;t sign another vendor contract this fiscal year. We already run Kubernetes for everything else, so why are we paying someone to schedule containers for us? All valid.&lt;/p>
&lt;p>Self-hosting a Flue agent on Kubernetes is straightforward. The trick is doing it without rebuilding the things managed serverless gives you for free: HTTP routing, autoscaling, scale-to-zero, ingress, TLS. That&amp;rsquo;s where Knative earns its keep.&lt;/p>
&lt;h3 id="why-knative">Why Knative&lt;/h3>
&lt;p>Knative is the K8s-native answer to serverless. You hand it a container image and a port. It hands you back a public URL, request-based autoscaling, scale-to-zero when nothing is calling, and TLS through cert-manager. The whole &amp;ldquo;deploy a webhook handler&amp;rdquo; thing collapses into a single Kubernetes resource.&lt;/p>
&lt;p>For a Flue agent specifically:&lt;/p>
&lt;ul>
&lt;li>The agent boots in a few hundred milliseconds. Cold start is fine.&lt;/li>
&lt;li>Webhook traffic is bursty. Scale-to-zero saves real money.&lt;/li>
&lt;li>Inbound traffic is HTTP only. No need for a service mesh.&lt;/li>
&lt;/ul>
&lt;p>OpenFAAS works too. So does spinning up a Deployment plus Service plus Ingress plus HPA by hand. I keep coming back to Knative because the Service resource is one file and the operational story (events, observability, autoscaling) is built in.&lt;/p>
&lt;h3 id="the-dockerfile">The Dockerfile&lt;/h3>
&lt;p>Flue ships as a Node CLI plus a function bundle. For deployment, build a small image with the function code and the SDK&amp;rsquo;s HTTP server in front:&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-dockerfile" data-lang="dockerfile">&lt;span class="line">&lt;span class="cl">&lt;span class="k">FROM&lt;/span>&lt;span class="s"> node:22-alpine AS deps&lt;/span>&lt;span class="err">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="err">&lt;/span>&lt;span class="k">WORKDIR&lt;/span>&lt;span class="s"> /app&lt;/span>&lt;span class="err">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="err">&lt;/span>&lt;span class="k">COPY&lt;/span> package.json package-lock.json ./&lt;span class="err">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="err">&lt;/span>&lt;span class="k">RUN&lt;/span> npm ci --omit&lt;span class="o">=&lt;/span>dev&lt;span class="err">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="err">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="err">&lt;/span>&lt;span class="k">FROM&lt;/span>&lt;span class="s"> node:22-alpine&lt;/span>&lt;span class="err">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="err">&lt;/span>&lt;span class="k">WORKDIR&lt;/span>&lt;span class="s"> /app&lt;/span>&lt;span class="err">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="err">&lt;/span>&lt;span class="k">COPY&lt;/span> --from&lt;span class="o">=&lt;/span>deps /app/node_modules ./node_modules&lt;span class="err">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="err">&lt;/span>&lt;span class="k">COPY&lt;/span> agent.ts jira.ts ./&lt;span class="err">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="err">&lt;/span>&lt;span class="k">COPY&lt;/span> skills ./skills&lt;span class="err">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="err">&lt;/span>&lt;span class="k">ENV&lt;/span> &lt;span class="nv">NODE_ENV&lt;/span>&lt;span class="o">=&lt;/span>production
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="k">EXPOSE&lt;/span>&lt;span class="s"> 8080&lt;/span>&lt;span class="err">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="err">&lt;/span>&lt;span class="k">CMD&lt;/span> &lt;span class="p">[&lt;/span>&lt;span class="s2">&amp;#34;npx&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="s2">&amp;#34;flue&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="s2">&amp;#34;serve&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="s2">&amp;#34;--host&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="s2">&amp;#34;0.0.0.0&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="s2">&amp;#34;--port&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="s2">&amp;#34;8080&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="s2">&amp;#34;agent.ts&amp;#34;&lt;/span>&lt;span class="p">]&lt;/span>&lt;span class="err">
&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>Two-stage build to keep the runtime image small. The skills directory comes along since the agent reads context files at runtime. &lt;code>flue serve&lt;/code> is the SDK&amp;rsquo;s HTTP entrypoint that turns &lt;code>export default&lt;/code> into a webhook handler at &lt;code>/&lt;/code>.&lt;/p>
&lt;p>If you&amp;rsquo;re running a Bun-flavored Flue agent, swap the base image for &lt;code>oven/bun:1-alpine&lt;/code> and the CMD for &lt;code>bun run agent.ts&lt;/code>. Same shape, fewer layers.&lt;/p>
&lt;p>Push to whatever registry you&amp;rsquo;re using:&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-bash" data-lang="bash">&lt;span class="line">&lt;span class="cl">docker build -t registry.internal/agents/jira-triage:0.1.0 .
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">docker push registry.internal/agents/jira-triage:0.1.0
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;h3 id="the-knative-service">The Knative Service&lt;/h3>
&lt;p>The Service resource is the whole thing.&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-yaml" data-lang="yaml">&lt;span class="line">&lt;span class="cl">&lt;span class="nt">apiVersion&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">serving.knative.dev/v1&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">&lt;/span>&lt;span class="nt">kind&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">Service&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">&lt;/span>&lt;span class="nt">metadata&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">name&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">jira-triage&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">namespace&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">agents&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">&lt;/span>&lt;span class="nt">spec&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">template&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">metadata&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">annotations&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">autoscaling.knative.dev/min-scale&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="s2">&amp;#34;0&amp;#34;&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">autoscaling.knative.dev/max-scale&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="s2">&amp;#34;10&amp;#34;&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">autoscaling.knative.dev/target&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="s2">&amp;#34;5&amp;#34;&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">spec&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">timeoutSeconds&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="m">90&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">containerConcurrency&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="m">1&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">containers&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>- &lt;span class="nt">image&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">registry.internal/agents/jira-triage:0.1.0&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">ports&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>- &lt;span class="nt">containerPort&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="m">8080&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">envFrom&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>- &lt;span class="nt">secretRef&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">name&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">jira-triage-secrets&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">resources&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">requests&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">cpu&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">100m&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">memory&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">256Mi&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">limits&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">cpu&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">1000m&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">memory&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">1Gi&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>The annotations are doing real work:&lt;/p>
&lt;ul>
&lt;li>&lt;code>min-scale: 0&lt;/code> lets the agent drop to zero replicas when nothing is calling it. That is the entire point of running this on Knative instead of a plain Deployment.&lt;/li>
&lt;li>&lt;code>max-scale: 10&lt;/code> caps blast radius. If a JIRA automation rule misfires and floods the webhook, it won&amp;rsquo;t take down the cluster.&lt;/li>
&lt;li>&lt;code>target: 5&lt;/code> plus &lt;code>containerConcurrency: 1&lt;/code> tell Knative each pod handles one in-flight request at a time, and to scale up once five concurrent requests are queued.&lt;/li>
&lt;/ul>
&lt;p>&lt;code>timeoutSeconds: 90&lt;/code> is the upper bound for a single request. Most agent runs finish in 10 to 30 seconds. Ninety gives a comfortable cushion for the slow case where the model is grinding through a long classification context. If you regularly need longer than that, you&amp;rsquo;ve left &amp;ldquo;webhook handler&amp;rdquo; territory and want a workflow runner like Argo or a queue.&lt;/p>
&lt;p>&lt;code>containerConcurrency: 1&lt;/code> is the safe default. Flue agents tend to hold an open session and a model connection per request. Sharing one Node process across multiple in-flight model calls works, but the concurrency math gets complicated and you trade simplicity for a small CPU win. Start at 1, tune later if cost matters.&lt;/p>
&lt;h3 id="secrets">Secrets&lt;/h3>
&lt;p>Secrets stay out of the Service manifest. Drop them in a Secret and &lt;code>envFrom&lt;/code> them in:&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-yaml" data-lang="yaml">&lt;span class="line">&lt;span class="cl">&lt;span class="nt">apiVersion&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">v1&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">&lt;/span>&lt;span class="nt">kind&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">Secret&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">&lt;/span>&lt;span class="nt">metadata&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">name&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">jira-triage-secrets&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">namespace&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">agents&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">&lt;/span>&lt;span class="nt">type&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">Opaque&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">&lt;/span>&lt;span class="nt">stringData&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">ANTHROPIC_API_KEY&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="s2">&amp;#34;sk-ant-...&amp;#34;&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">JIRA_HOST&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="s2">&amp;#34;yourorg.atlassian.net&amp;#34;&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">JIRA_EMAIL&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="s2">&amp;#34;triage-bot@yourorg.com&amp;#34;&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">JIRA_TOKEN&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="s2">&amp;#34;...&amp;#34;&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>If you&amp;rsquo;re running External Secrets Operator or Vault Secrets Operator, point at the upstream store instead of stuffing keys into a YAML you&amp;rsquo;ll commit by accident. The Flue agent doesn&amp;rsquo;t care where the env vars come from. It just reads them.&lt;/p>
&lt;h3 id="the-webhook-url">The webhook URL&lt;/h3>
&lt;p>Knative gives the Service a public URL on whatever domain your cluster operator has configured. You&amp;rsquo;ll see it in &lt;code>kubectl get ksvc -n agents&lt;/code>:&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-bash" data-lang="bash">&lt;span class="line">&lt;span class="cl">kubectl get ksvc jira-triage -n agents
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">NAME URL READY
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">jira-triage https://jira-triage.agents.example.internal True
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>Point your JIRA automation rule at that URL with whatever auth you want on it. Knative supports request authentication through the standard ingress controllers, so if you want HMAC verification, JWT, or a static bearer token, the Service is just an HTTP server and you wire it in upstream.&lt;/p>
&lt;p>For HMAC (JIRA can sign webhooks), I add a small middleware layer in the Flue handler itself rather than at the ingress. That way the Service stays portable and the auth lives next to the agent code.&lt;/p>
&lt;h3 id="scaling-the-operators-view">Scaling, the operator&amp;rsquo;s view&lt;/h3>
&lt;p>Knative does its own pod scaling, but the autoscaler still has to live somewhere. The defaults are reasonable, and if your team is already running KPA (the Knative Pod Autoscaler), you don&amp;rsquo;t need to do anything. If you&amp;rsquo;d rather use HPA, swap the annotation:&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-yaml" data-lang="yaml">&lt;span class="line">&lt;span class="cl">&lt;span class="nt">autoscaling.knative.dev/class&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="s2">&amp;#34;hpa.autoscaling.knative.dev&amp;#34;&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>KPA reacts faster to bursts (sub-second). HPA is steadier and integrates with your existing custom metrics pipeline. For agent workloads I&amp;rsquo;d default to KPA: bursts of webhook traffic show up first as concurrency, which KPA scales on natively.&lt;/p>
&lt;h3 id="what-you-give-up">What you give up&lt;/h3>
&lt;p>&lt;strong>Cold starts.&lt;/strong> The first request after a quiet period will pay 1 to 3 seconds for the Node process to come up. That&amp;rsquo;s fine for JIRA webhooks. It would be painful for a chatbot facing humans. If you can&amp;rsquo;t tolerate cold starts, set &lt;code>min-scale: 1&lt;/code> and pay for one always-warm pod.&lt;/p>
&lt;p>&lt;strong>Operational ownership.&lt;/strong> Cloudflare hands you &amp;ldquo;the function ran, here&amp;rsquo;s the log, here&amp;rsquo;s the trace.&amp;rdquo; With Knative you own the autoscaler tuning, the ingress, the cert rotation, the registry, and the K8s upgrade cycle. If your team already runs that, the marginal cost is small. If not, you might be reaching for the wrong tool.&lt;/p>
&lt;p>&lt;strong>Egress quotas.&lt;/strong> Knative defaults to no egress filtering. If you&amp;rsquo;re in a regulated environment, you&amp;rsquo;ll want NetworkPolicies in front of the agent&amp;rsquo;s namespace so it can only reach the model API and your JIRA host. The default is &amp;ldquo;open egress,&amp;rdquo; which is probably fine for the prototype and definitely not fine for prod.&lt;/p>
&lt;h3 id="observability">Observability&lt;/h3>
&lt;p>The agent already returns structured JSON for each invocation (the action it took, the reasoning summary, the issue key). On Knative, every request shows up in &lt;code>kubectl logs&lt;/code> and the queue-proxy sidecar exposes Prometheus metrics:&lt;/p>
&lt;ul>
&lt;li>&lt;code>revision_request_count&lt;/code> per Service revision&lt;/li>
&lt;li>&lt;code>request_latency&lt;/code> histogram&lt;/li>
&lt;li>&lt;code>concurrent_requests&lt;/code> gauge&lt;/li>
&lt;/ul>
&lt;p>Pair that with a ServiceMonitor and you get a dashboard for free. Add a structured logger inside the agent (pino, &lt;code>console.log&lt;/code> of structured objects, whatever your cluster&amp;rsquo;s log pipeline can parse) and you&amp;rsquo;ve got correlated logs and metrics without any custom infra.&lt;/p>
&lt;p>For traces: enable Knative&amp;rsquo;s OpenTelemetry support, or bolt on the OpenTelemetry SDK inside the Flue handler. Whichever your tracing stack already prefers.&lt;/p>
&lt;h3 id="when-this-is-the-right-tool">When this is the right tool&lt;/h3>
&lt;p>Self-host the Flue agent on Knative when:&lt;/p>
&lt;ul>
&lt;li>Your data plane has to stay in your VPC.&lt;/li>
&lt;li>You already run Kubernetes and have an SRE team that knows it.&lt;/li>
&lt;li>You have multiple agents and want a uniform deployment story across them all.&lt;/li>
&lt;li>Webhook traffic is bursty enough that &amp;ldquo;always running on a VM&amp;rdquo; is overkill, but consistent enough that you&amp;rsquo;d rather not pay per-request to a third party.&lt;/li>
&lt;/ul>
&lt;p>Reach for managed serverless when:&lt;/p>
&lt;ul>
&lt;li>You don&amp;rsquo;t have a K8s team, or you have one and they&amp;rsquo;re already at capacity.&lt;/li>
&lt;li>You want one less thing to babysit.&lt;/li>
&lt;li>The data is fine to leave the building.&lt;/li>
&lt;/ul>
&lt;p>Both shapes work. Both are real. The Flue agent code doesn&amp;rsquo;t change between them, which is the part I keep finding satisfying: you can develop on Workers, prove the agent out in the small, and self-host the same code in your cluster the day someone in security says &amp;ldquo;no, that data stays put.&amp;rdquo;&lt;/p></description></item><item><title>Flue, Part 2: Stop Triaging JIRA By Hand</title><link>https://houdeshell.dev/post/2026-05-09_flue-jira-triage/</link><pubDate>Sat, 02 May 2026 15:00:00 -0400</pubDate><guid>https://houdeshell.dev/post/2026-05-09_flue-jira-triage/</guid><description>&lt;p>&lt;a href="https://houdeshell.dev/post/2026-05-02_flue-mastra-typescript-agents/">Part 1&lt;/a> was the framework pitch. This is the agent I actually wanted to build with it.&lt;/p>
&lt;p>The setup is something I keep seeing across teams: JIRA triage is brutal. Tickets pile up in the new-issues queue. An EM or tech lead spends an hour every morning sorting them. Someone classifies, someone re-classifies, someone routes, someone re-routes. Half of them get assigned to the wrong team and bounce around for two days before landing.&lt;/p>
&lt;p>The agent I&amp;rsquo;m going to walk through does that triage work. It reads each new ticket, classifies it, picks a team and a person, and either assigns it or comments on the ticket asking for human eyes. It never guesses. If it isn&amp;rsquo;t confident, the human gets a structured handoff instead of a wrong assignee.&lt;/p>
&lt;h3 id="the-plan">The plan&lt;/h3>
&lt;p>Two skills:&lt;/p>
&lt;ol>
&lt;li>&lt;code>classify-ticket&lt;/code>: reads the ticket and picks a category, domain, and priority. Self-reports confidence.&lt;/li>
&lt;li>&lt;code>route-ticket&lt;/code>: given that classification, picks a team and a specific assignee. Also self-reports confidence.&lt;/li>
&lt;/ol>
&lt;p>Then a tiny piece of glue:&lt;/p>
&lt;ul>
&lt;li>If both skills come back high-confidence, assign + label, log, done.&lt;/li>
&lt;li>Otherwise, comment on the ticket with the agent&amp;rsquo;s reasoning, label &lt;code>needs-triage&lt;/code>, leave the assignee blank.&lt;/li>
&lt;/ul>
&lt;p>The whole thing runs on a JIRA webhook. New issue created? Webhook fires, Flue runs, two API calls back to JIRA, response sent. Total wall-clock time: a few seconds.&lt;/p>
&lt;h3 id="the-agent">The agent&lt;/h3>
&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-typescript" data-lang="typescript">&lt;span class="line">&lt;span class="cl">&lt;span class="kr">import&lt;/span> &lt;span class="kr">type&lt;/span> &lt;span class="p">{&lt;/span> &lt;span class="nx">FlueContext&lt;/span> &lt;span class="p">}&lt;/span> &lt;span class="kr">from&lt;/span> &lt;span class="s1">&amp;#39;@flue/sdk/client&amp;#39;&lt;/span>&lt;span class="p">;&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="kr">import&lt;/span> &lt;span class="o">*&lt;/span> &lt;span class="kr">as&lt;/span> &lt;span class="nx">v&lt;/span> &lt;span class="kr">from&lt;/span> &lt;span class="s1">&amp;#39;valibot&amp;#39;&lt;/span>&lt;span class="p">;&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="kr">import&lt;/span> &lt;span class="o">*&lt;/span> &lt;span class="kr">as&lt;/span> &lt;span class="nx">jira&lt;/span> &lt;span class="kr">from&lt;/span> &lt;span class="s1">&amp;#39;./jira&amp;#39;&lt;/span>&lt;span class="p">;&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="kr">export&lt;/span> &lt;span class="kr">const&lt;/span> &lt;span class="nx">triggers&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="p">{&lt;/span> &lt;span class="nx">webhook&lt;/span>: &lt;span class="kt">true&lt;/span> &lt;span class="p">};&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="kr">const&lt;/span> &lt;span class="nx">Confidence&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="nx">v&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">picklist&lt;/span>&lt;span class="p">([&lt;/span>&lt;span class="s1">&amp;#39;high&amp;#39;&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="s1">&amp;#39;medium&amp;#39;&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="s1">&amp;#39;low&amp;#39;&lt;/span>&lt;span class="p">]);&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="kr">const&lt;/span> &lt;span class="nx">Classification&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="nx">v&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="kt">object&lt;/span>&lt;span class="p">({&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="nx">category&lt;/span>: &lt;span class="kt">v.picklist&lt;/span>&lt;span class="p">([&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="s1">&amp;#39;bug&amp;#39;&lt;/span>&lt;span class="p">,&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="s1">&amp;#39;feature-request&amp;#39;&lt;/span>&lt;span class="p">,&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="s1">&amp;#39;support&amp;#39;&lt;/span>&lt;span class="p">,&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="s1">&amp;#39;security&amp;#39;&lt;/span>&lt;span class="p">,&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="s1">&amp;#39;infra&amp;#39;&lt;/span>&lt;span class="p">,&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="s1">&amp;#39;docs&amp;#39;&lt;/span>&lt;span class="p">,&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="s1">&amp;#39;duplicate&amp;#39;&lt;/span>&lt;span class="p">,&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="s1">&amp;#39;spam&amp;#39;&lt;/span>&lt;span class="p">,&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="p">]),&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="nx">domain&lt;/span>: &lt;span class="kt">v.picklist&lt;/span>&lt;span class="p">([&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="s1">&amp;#39;frontend&amp;#39;&lt;/span>&lt;span class="p">,&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="s1">&amp;#39;backend&amp;#39;&lt;/span>&lt;span class="p">,&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="s1">&amp;#39;database&amp;#39;&lt;/span>&lt;span class="p">,&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="s1">&amp;#39;auth&amp;#39;&lt;/span>&lt;span class="p">,&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="s1">&amp;#39;billing&amp;#39;&lt;/span>&lt;span class="p">,&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="s1">&amp;#39;reporting&amp;#39;&lt;/span>&lt;span class="p">,&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="s1">&amp;#39;kubernetes&amp;#39;&lt;/span>&lt;span class="p">,&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="s1">&amp;#39;unknown&amp;#39;&lt;/span>&lt;span class="p">,&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="p">]),&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="nx">priority&lt;/span>: &lt;span class="kt">v.picklist&lt;/span>&lt;span class="p">([&lt;/span>&lt;span class="s1">&amp;#39;low&amp;#39;&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="s1">&amp;#39;medium&amp;#39;&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="s1">&amp;#39;high&amp;#39;&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="s1">&amp;#39;critical&amp;#39;&lt;/span>&lt;span class="p">]),&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="nx">reasoning&lt;/span>: &lt;span class="kt">v.string&lt;/span>&lt;span class="p">(),&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="nx">confidence&lt;/span>: &lt;span class="kt">Confidence&lt;/span>&lt;span class="p">,&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="p">});&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="kr">const&lt;/span> &lt;span class="nx">Routing&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="nx">v&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="kt">object&lt;/span>&lt;span class="p">({&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="nx">team&lt;/span>: &lt;span class="kt">v.string&lt;/span>&lt;span class="p">(),&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="nx">assignee&lt;/span>: &lt;span class="kt">v.union&lt;/span>&lt;span class="p">([&lt;/span>&lt;span class="nx">v&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="kt">string&lt;/span>&lt;span class="p">(),&lt;/span> &lt;span class="nx">v&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="kc">null&lt;/span>&lt;span class="p">()]),&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="nx">reasoning&lt;/span>: &lt;span class="kt">v.string&lt;/span>&lt;span class="p">(),&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="nx">confidence&lt;/span>: &lt;span class="kt">Confidence&lt;/span>&lt;span class="p">,&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="p">});&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="kr">export&lt;/span> &lt;span class="k">default&lt;/span> &lt;span class="kr">async&lt;/span> &lt;span class="kd">function&lt;/span> &lt;span class="p">({&lt;/span> &lt;span class="nx">init&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="nx">payload&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="nx">env&lt;/span> &lt;span class="p">}&lt;/span>&lt;span class="o">:&lt;/span> &lt;span class="nx">FlueContext&lt;/span>&lt;span class="p">)&lt;/span> &lt;span class="p">{&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="kr">const&lt;/span> &lt;span class="p">{&lt;/span> &lt;span class="nx">issue&lt;/span> &lt;span class="p">}&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="nx">payload&lt;/span>&lt;span class="p">;&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="kr">const&lt;/span> &lt;span class="nx">agent&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="k">await&lt;/span> &lt;span class="nx">init&lt;/span>&lt;span class="p">({&lt;/span> &lt;span class="nx">model&lt;/span>&lt;span class="o">:&lt;/span> &lt;span class="s1">&amp;#39;anthropic/claude-opus-4-7&amp;#39;&lt;/span> &lt;span class="p">});&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="kr">const&lt;/span> &lt;span class="nx">session&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="k">await&lt;/span> &lt;span class="nx">agent&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">session&lt;/span>&lt;span class="p">();&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="kr">const&lt;/span> &lt;span class="nx">classification&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="k">await&lt;/span> &lt;span class="nx">session&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">skill&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="s1">&amp;#39;classify-ticket&amp;#39;&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="p">{&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="nx">args&lt;/span>&lt;span class="o">:&lt;/span> &lt;span class="p">{&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="nx">summary&lt;/span>: &lt;span class="kt">issue.fields.summary&lt;/span>&lt;span class="p">,&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="nx">description&lt;/span>: &lt;span class="kt">issue.fields.description&lt;/span>&lt;span class="p">,&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="nx">reporter&lt;/span>: &lt;span class="kt">issue.fields.reporter.displayName&lt;/span>&lt;span class="p">,&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="nx">labels&lt;/span>: &lt;span class="kt">issue.fields.labels&lt;/span> &lt;span class="o">??&lt;/span> &lt;span class="p">[],&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="p">},&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="nx">result&lt;/span>: &lt;span class="kt">Classification&lt;/span>&lt;span class="p">,&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="p">});&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="kr">const&lt;/span> &lt;span class="nx">routing&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="k">await&lt;/span> &lt;span class="nx">session&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">skill&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="s1">&amp;#39;route-ticket&amp;#39;&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="p">{&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="nx">args&lt;/span>&lt;span class="o">:&lt;/span> &lt;span class="p">{&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="nx">classification&lt;/span>&lt;span class="p">,&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="nx">summary&lt;/span>: &lt;span class="kt">issue.fields.summary&lt;/span>&lt;span class="p">,&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="nx">description&lt;/span>: &lt;span class="kt">issue.fields.description&lt;/span>&lt;span class="p">,&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="p">},&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="nx">result&lt;/span>: &lt;span class="kt">Routing&lt;/span>&lt;span class="p">,&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="p">});&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="kr">const&lt;/span> &lt;span class="nx">confident&lt;/span> &lt;span class="o">=&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="nx">classification&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">confidence&lt;/span> &lt;span class="o">===&lt;/span> &lt;span class="s1">&amp;#39;high&amp;#39;&lt;/span> &lt;span class="o">&amp;amp;&amp;amp;&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="nx">routing&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">confidence&lt;/span> &lt;span class="o">===&lt;/span> &lt;span class="s1">&amp;#39;high&amp;#39;&lt;/span> &lt;span class="o">&amp;amp;&amp;amp;&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="nx">routing&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">assignee&lt;/span> &lt;span class="o">!=&lt;/span> &lt;span class="kc">null&lt;/span>&lt;span class="p">;&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="k">if&lt;/span> &lt;span class="p">(&lt;/span>&lt;span class="nx">confident&lt;/span>&lt;span class="p">)&lt;/span> &lt;span class="p">{&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="k">await&lt;/span> &lt;span class="nx">jira&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">assignIssue&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="nx">env&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="nx">issue&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">key&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="nx">routing&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">assignee&lt;/span>&lt;span class="o">!&lt;/span>&lt;span class="p">);&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="k">await&lt;/span> &lt;span class="nx">jira&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">applyLabels&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="nx">env&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="nx">issue&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">key&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="p">[&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="sb">`category/&lt;/span>&lt;span class="si">${&lt;/span>&lt;span class="nx">classification&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">category&lt;/span>&lt;span class="si">}&lt;/span>&lt;span class="sb">`&lt;/span>&lt;span class="p">,&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="sb">`domain/&lt;/span>&lt;span class="si">${&lt;/span>&lt;span class="nx">classification&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">domain&lt;/span>&lt;span class="si">}&lt;/span>&lt;span class="sb">`&lt;/span>&lt;span class="p">,&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="sb">`priority/&lt;/span>&lt;span class="si">${&lt;/span>&lt;span class="nx">classification&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">priority&lt;/span>&lt;span class="si">}&lt;/span>&lt;span class="sb">`&lt;/span>&lt;span class="p">,&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="sb">`team/&lt;/span>&lt;span class="si">${&lt;/span>&lt;span class="nx">routing&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">team&lt;/span>&lt;span class="si">}&lt;/span>&lt;span class="sb">`&lt;/span>&lt;span class="p">,&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="p">]);&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="k">return&lt;/span> &lt;span class="p">{&lt;/span> &lt;span class="nx">action&lt;/span>&lt;span class="o">:&lt;/span> &lt;span class="s1">&amp;#39;assigned&amp;#39;&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="nx">issue&lt;/span>: &lt;span class="kt">issue.key&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="p">...&lt;/span>&lt;span class="nx">routing&lt;/span> &lt;span class="p">};&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="p">}&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="k">return&lt;/span> &lt;span class="k">await&lt;/span> &lt;span class="nx">escalate&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="nx">env&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="nx">issue&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">key&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="nx">classification&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="nx">routing&lt;/span>&lt;span class="p">);&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="p">}&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="kr">async&lt;/span> &lt;span class="kd">function&lt;/span> &lt;span class="nx">escalate&lt;/span>&lt;span class="p">(&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="nx">env&lt;/span>: &lt;span class="kt">any&lt;/span>&lt;span class="p">,&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="nx">key&lt;/span>: &lt;span class="kt">string&lt;/span>&lt;span class="p">,&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="nx">c&lt;/span>: &lt;span class="kt">v.InferOutput&lt;/span>&lt;span class="p">&amp;lt;&lt;/span>&lt;span class="nt">typeof&lt;/span> &lt;span class="na">Classification&lt;/span>&lt;span class="p">&amp;gt;,&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="nx">r&lt;/span>: &lt;span class="kt">v.InferOutput&lt;/span>&lt;span class="p">&amp;lt;&lt;/span>&lt;span class="nt">typeof&lt;/span> &lt;span class="na">Routing&lt;/span>&lt;span class="p">&amp;gt;,&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="p">)&lt;/span> &lt;span class="p">{&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="kr">const&lt;/span> &lt;span class="nx">body&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="p">[&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="sb">`Triage agent escalated this ticket for human review.`&lt;/span>&lt;span class="p">,&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="sb">``&lt;/span>&lt;span class="p">,&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="sb">`*Classification (&lt;/span>&lt;span class="si">${&lt;/span>&lt;span class="nx">c&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">confidence&lt;/span>&lt;span class="si">}&lt;/span>&lt;span class="sb">):*`&lt;/span>&lt;span class="p">,&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="sb">`- Category: &lt;/span>&lt;span class="si">${&lt;/span>&lt;span class="nx">c&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">category&lt;/span>&lt;span class="si">}&lt;/span>&lt;span class="sb">`&lt;/span>&lt;span class="p">,&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="sb">`- Domain: &lt;/span>&lt;span class="si">${&lt;/span>&lt;span class="nx">c&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">domain&lt;/span>&lt;span class="si">}&lt;/span>&lt;span class="sb">`&lt;/span>&lt;span class="p">,&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="sb">`- Priority: &lt;/span>&lt;span class="si">${&lt;/span>&lt;span class="nx">c&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">priority&lt;/span>&lt;span class="si">}&lt;/span>&lt;span class="sb">`&lt;/span>&lt;span class="p">,&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="sb">`- Reasoning: &lt;/span>&lt;span class="si">${&lt;/span>&lt;span class="nx">c&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">reasoning&lt;/span>&lt;span class="si">}&lt;/span>&lt;span class="sb">`&lt;/span>&lt;span class="p">,&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="sb">``&lt;/span>&lt;span class="p">,&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="sb">`*Suggested routing (&lt;/span>&lt;span class="si">${&lt;/span>&lt;span class="nx">r&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">confidence&lt;/span>&lt;span class="si">}&lt;/span>&lt;span class="sb">):*`&lt;/span>&lt;span class="p">,&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="sb">`- Team: &lt;/span>&lt;span class="si">${&lt;/span>&lt;span class="nx">r&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">team&lt;/span>&lt;span class="si">}&lt;/span>&lt;span class="sb">`&lt;/span>&lt;span class="p">,&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="sb">`- Assignee: &lt;/span>&lt;span class="si">${&lt;/span>&lt;span class="nx">r&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">assignee&lt;/span> &lt;span class="o">??&lt;/span> &lt;span class="s1">&amp;#39;unsure&amp;#39;&lt;/span>&lt;span class="si">}&lt;/span>&lt;span class="sb">`&lt;/span>&lt;span class="p">,&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="sb">`- Reasoning: &lt;/span>&lt;span class="si">${&lt;/span>&lt;span class="nx">r&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">reasoning&lt;/span>&lt;span class="si">}&lt;/span>&lt;span class="sb">`&lt;/span>&lt;span class="p">,&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="p">].&lt;/span>&lt;span class="nx">join&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="s1">&amp;#39;\n&amp;#39;&lt;/span>&lt;span class="p">);&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="k">await&lt;/span> &lt;span class="nx">jira&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">addComment&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="nx">env&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="nx">key&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="nx">body&lt;/span>&lt;span class="p">);&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="k">await&lt;/span> &lt;span class="nx">jira&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">applyLabels&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="nx">env&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="nx">key&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="p">[&lt;/span>&lt;span class="s1">&amp;#39;needs-triage&amp;#39;&lt;/span>&lt;span class="p">]);&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="k">return&lt;/span> &lt;span class="p">{&lt;/span> &lt;span class="nx">action&lt;/span>&lt;span class="o">:&lt;/span> &lt;span class="s1">&amp;#39;escalated&amp;#39;&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="nx">issue&lt;/span>: &lt;span class="kt">key&lt;/span> &lt;span class="p">};&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="p">}&lt;/span>
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>Two skill calls, one branch. The whole file fits on a single screen.&lt;/p>
&lt;p>The JIRA helper is small enough to inline. Three calls: assign, comment, label.&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-typescript" data-lang="typescript">&lt;span class="line">&lt;span class="cl">&lt;span class="c1">// jira.ts
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="c1">&lt;/span>&lt;span class="kr">const&lt;/span> &lt;span class="nx">base&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="p">(&lt;/span>&lt;span class="nx">env&lt;/span>: &lt;span class="kt">any&lt;/span>&lt;span class="p">)&lt;/span> &lt;span class="o">=&amp;gt;&lt;/span> &lt;span class="sb">`https://&lt;/span>&lt;span class="si">${&lt;/span>&lt;span class="nx">env&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">JIRA_HOST&lt;/span>&lt;span class="si">}&lt;/span>&lt;span class="sb">/rest/api/3`&lt;/span>&lt;span class="p">;&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="kr">const&lt;/span> &lt;span class="nx">headers&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="p">(&lt;/span>&lt;span class="nx">env&lt;/span>: &lt;span class="kt">any&lt;/span>&lt;span class="p">)&lt;/span> &lt;span class="o">=&amp;gt;&lt;/span> &lt;span class="p">({&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="nx">Authorization&lt;/span>&lt;span class="o">:&lt;/span> &lt;span class="sb">`Basic &lt;/span>&lt;span class="si">${&lt;/span>&lt;span class="nx">btoa&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="sb">`&lt;/span>&lt;span class="si">${&lt;/span>&lt;span class="nx">env&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">JIRA_EMAIL&lt;/span>&lt;span class="si">}&lt;/span>&lt;span class="sb">:&lt;/span>&lt;span class="si">${&lt;/span>&lt;span class="nx">env&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">JIRA_TOKEN&lt;/span>&lt;span class="si">}&lt;/span>&lt;span class="sb">`&lt;/span>&lt;span class="p">)&lt;/span>&lt;span class="si">}&lt;/span>&lt;span class="sb">`&lt;/span>&lt;span class="p">,&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="s1">&amp;#39;Content-Type&amp;#39;&lt;/span>&lt;span class="o">:&lt;/span> &lt;span class="s1">&amp;#39;application/json&amp;#39;&lt;/span>&lt;span class="p">,&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="p">});&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="kr">export&lt;/span> &lt;span class="kr">async&lt;/span> &lt;span class="kd">function&lt;/span> &lt;span class="nx">assignIssue&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="nx">env&lt;/span>: &lt;span class="kt">any&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="nx">key&lt;/span>: &lt;span class="kt">string&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="nx">accountId&lt;/span>: &lt;span class="kt">string&lt;/span>&lt;span class="p">)&lt;/span> &lt;span class="p">{&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="k">await&lt;/span> &lt;span class="nx">fetch&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="sb">`&lt;/span>&lt;span class="si">${&lt;/span>&lt;span class="nx">base&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="nx">env&lt;/span>&lt;span class="p">)&lt;/span>&lt;span class="si">}&lt;/span>&lt;span class="sb">/issue/&lt;/span>&lt;span class="si">${&lt;/span>&lt;span class="nx">key&lt;/span>&lt;span class="si">}&lt;/span>&lt;span class="sb">/assignee`&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="p">{&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="nx">method&lt;/span>&lt;span class="o">:&lt;/span> &lt;span class="s1">&amp;#39;PUT&amp;#39;&lt;/span>&lt;span class="p">,&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="nx">headers&lt;/span>: &lt;span class="kt">headers&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="nx">env&lt;/span>&lt;span class="p">),&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="nx">body&lt;/span>: &lt;span class="kt">JSON.stringify&lt;/span>&lt;span class="p">({&lt;/span> &lt;span class="nx">accountId&lt;/span> &lt;span class="p">}),&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="p">});&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="p">}&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="kr">export&lt;/span> &lt;span class="kr">async&lt;/span> &lt;span class="kd">function&lt;/span> &lt;span class="nx">addComment&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="nx">env&lt;/span>: &lt;span class="kt">any&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="nx">key&lt;/span>: &lt;span class="kt">string&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="nx">text&lt;/span>: &lt;span class="kt">string&lt;/span>&lt;span class="p">)&lt;/span> &lt;span class="p">{&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="k">await&lt;/span> &lt;span class="nx">fetch&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="sb">`&lt;/span>&lt;span class="si">${&lt;/span>&lt;span class="nx">base&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="nx">env&lt;/span>&lt;span class="p">)&lt;/span>&lt;span class="si">}&lt;/span>&lt;span class="sb">/issue/&lt;/span>&lt;span class="si">${&lt;/span>&lt;span class="nx">key&lt;/span>&lt;span class="si">}&lt;/span>&lt;span class="sb">/comment`&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="p">{&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="nx">method&lt;/span>&lt;span class="o">:&lt;/span> &lt;span class="s1">&amp;#39;POST&amp;#39;&lt;/span>&lt;span class="p">,&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="nx">headers&lt;/span>: &lt;span class="kt">headers&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="nx">env&lt;/span>&lt;span class="p">),&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="nx">body&lt;/span>: &lt;span class="kt">JSON.stringify&lt;/span>&lt;span class="p">({&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="nx">body&lt;/span>&lt;span class="o">:&lt;/span> &lt;span class="p">{&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="kr">type&lt;/span>&lt;span class="o">:&lt;/span> &lt;span class="s1">&amp;#39;doc&amp;#39;&lt;/span>&lt;span class="p">,&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="nx">version&lt;/span>: &lt;span class="kt">1&lt;/span>&lt;span class="p">,&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="nx">content&lt;/span>&lt;span class="o">:&lt;/span> &lt;span class="p">[&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="p">{&lt;/span> &lt;span class="kr">type&lt;/span>&lt;span class="o">:&lt;/span> &lt;span class="s1">&amp;#39;paragraph&amp;#39;&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="nx">content&lt;/span>&lt;span class="o">:&lt;/span> &lt;span class="p">[{&lt;/span> &lt;span class="kr">type&lt;/span>&lt;span class="o">:&lt;/span> &lt;span class="s1">&amp;#39;text&amp;#39;&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="nx">text&lt;/span> &lt;span class="p">}]&lt;/span> &lt;span class="p">},&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="p">],&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="p">},&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="p">}),&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="p">});&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="p">}&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="kr">export&lt;/span> &lt;span class="kr">async&lt;/span> &lt;span class="kd">function&lt;/span> &lt;span class="nx">applyLabels&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="nx">env&lt;/span>: &lt;span class="kt">any&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="nx">key&lt;/span>: &lt;span class="kt">string&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="nx">labels&lt;/span>: &lt;span class="kt">string&lt;/span>&lt;span class="p">[])&lt;/span> &lt;span class="p">{&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="k">await&lt;/span> &lt;span class="nx">fetch&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="sb">`&lt;/span>&lt;span class="si">${&lt;/span>&lt;span class="nx">base&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="nx">env&lt;/span>&lt;span class="p">)&lt;/span>&lt;span class="si">}&lt;/span>&lt;span class="sb">/issue/&lt;/span>&lt;span class="si">${&lt;/span>&lt;span class="nx">key&lt;/span>&lt;span class="si">}&lt;/span>&lt;span class="sb">`&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="p">{&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="nx">method&lt;/span>&lt;span class="o">:&lt;/span> &lt;span class="s1">&amp;#39;PUT&amp;#39;&lt;/span>&lt;span class="p">,&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="nx">headers&lt;/span>: &lt;span class="kt">headers&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="nx">env&lt;/span>&lt;span class="p">),&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="nx">body&lt;/span>: &lt;span class="kt">JSON.stringify&lt;/span>&lt;span class="p">({&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="nx">update&lt;/span>&lt;span class="o">:&lt;/span> &lt;span class="p">{&lt;/span> &lt;span class="nx">labels&lt;/span>: &lt;span class="kt">labels.map&lt;/span>&lt;span class="p">((&lt;/span>&lt;span class="nx">l&lt;/span>&lt;span class="p">)&lt;/span> &lt;span class="o">=&amp;gt;&lt;/span> &lt;span class="p">({&lt;/span> &lt;span class="nx">add&lt;/span>: &lt;span class="kt">l&lt;/span> &lt;span class="p">}))&lt;/span> &lt;span class="p">},&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="p">}),&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="p">});&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="p">}&lt;/span>
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;h3 id="the-skills">The skills&lt;/h3>
&lt;p>A Flue skill is a directory. It has a prompt template and zero or more context files the model reads before answering. That&amp;rsquo;s where the &amp;ldquo;advanced reasoning&amp;rdquo; actually lives, in the context the model gets to look at before it commits to a structured output.&lt;/p>
&lt;p>Here is &lt;code>skills/classify-ticket/prompt.md&lt;/code>:&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-text" data-lang="text">&lt;span class="line">&lt;span class="cl">You are a triage assistant for our engineering org. Classify the JIRA ticket
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">below.
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">Use these context files to understand the categories, domains, and our
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">priority rubric:
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">- /context/categories.md
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">- /context/domains.md
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">- /context/priority.md
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">- /context/historical-examples.md
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">Process:
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">1. Read the ticket and the context files.
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">2. Pick the single most accurate category and domain.
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">3. Pick a priority based on /context/priority.md, not based on how loudly
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> the reporter complains.
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">4. Set confidence based on how clean the match is. If you have to choose
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> between two domains, that is &amp;#34;medium&amp;#34; at best. If the ticket is too
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> vague to map confidently, that is &amp;#34;low&amp;#34;.
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">5. In `reasoning`, write 2 to 3 sentences explaining the call. This is
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> what a human will read if you escalate.
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">Never guess. We would rather have a human triage an ambiguous ticket than
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">auto-assign a wrong one.
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">The ticket:
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">- Summary: {{ args.summary }}
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">- Description: {{ args.description }}
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">- Reporter: {{ args.reporter }}
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">- Labels: {{ args.labels }}
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>Three things matter in that prompt. The structured output schema enforces that the model picks from a finite set of categories. The &amp;ldquo;never guess&amp;rdquo; instruction backed by the &lt;code>confidence&lt;/code> field gives it permission to bail out. The context files give it the org-specific knowledge that the base model can&amp;rsquo;t have.&lt;/p>
&lt;p>&lt;code>skills/route-ticket/&lt;/code> is the same shape. Its own prompt, its own context:&lt;/p>
&lt;ul>
&lt;li>&lt;code>/context/teams.md&lt;/code>: what each team owns, with examples.&lt;/li>
&lt;li>&lt;code>/context/people.md&lt;/code>: who&amp;rsquo;s on each team, what they specialize in, and who&amp;rsquo;s currently OOO.&lt;/li>
&lt;li>&lt;code>/context/ownership-by-domain.md&lt;/code>: table of domain to primary team, with edge cases.&lt;/li>
&lt;/ul>
&lt;p>The OOO file is the under-rated piece. If the model&amp;rsquo;s first pick is on vacation, it should fall back to the team&amp;rsquo;s secondary, not barrel through with a stale assignment that nobody picks up for a week.&lt;/p>
&lt;h3 id="what-confident-actually-means-here">What &amp;ldquo;confident&amp;rdquo; actually means here&lt;/h3>
&lt;p>The confidence field isn&amp;rsquo;t magic. It&amp;rsquo;s the model self-reporting after reading the prompt and the context files. It&amp;rsquo;s wrong sometimes. Two things make it less wrong:&lt;/p>
&lt;p>&lt;strong>Two paths, one decision.&lt;/strong> I run both skills before deciding. A &amp;ldquo;high&amp;rdquo; on classification but &amp;ldquo;medium&amp;rdquo; on routing means we ask a human, even though the model thinks it has half the answer. In practice, &amp;ldquo;the routing is shaky&amp;rdquo; is the most common reason to escalate, and I want that signal preserved instead of averaged away.&lt;/p>
&lt;p>&lt;strong>No null assignees on the happy path.&lt;/strong> If the routing skill returns &lt;code>assignee: null&lt;/code> (it knows the team but not the person), I treat that as not-confident even if the model self-reports &amp;ldquo;high&amp;rdquo;. The shape of the result is doing some of the gating work for me.&lt;/p>
&lt;p>You could tighten this further. Numeric confidence with explicit thresholds, an explicit &amp;ldquo;needs more info&amp;rdquo; exit code from the skill, retries with deeper context. I haven&amp;rsquo;t needed those yet. The two-skill, two-confidence pattern catches the bulk of bad routings before they happen.&lt;/p>
&lt;h3 id="the-escalation-comment-is-the-product">The escalation comment is the product&lt;/h3>
&lt;p>When the agent isn&amp;rsquo;t sure, the JIRA comment is where it earns its keep. Look at what it writes:&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-text" data-lang="text">&lt;span class="line">&lt;span class="cl">Triage agent escalated this ticket for human review.
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">*Classification (medium):*
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">- Category: support
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">- Domain: billing
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">- Priority: medium
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">- Reasoning: Customer reports a charge they don&amp;#39;t recognize. Could be
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> a duplicate from the recent retry storm or a real billing bug. Treating
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> as support until billing confirms which one.
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">*Suggested routing (low):*
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">- Team: billing
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">- Assignee: unsure
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">- Reasoning: Billing team owns this domain, but they&amp;#39;re rotating an
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> on-call this week and I can&amp;#39;t tell from /context/people.md who is
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> actually picking things up today. Recommend an EM eyeball this and
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> route.
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>A human reading that has all the work pre-loaded. The category guess. The domain guess. The reasoning. The reason it stopped short. They&amp;rsquo;re not triaging from scratch. They&amp;rsquo;re auditing a draft.&lt;/p>
&lt;p>That&amp;rsquo;s the actual UX win. Even when the agent fails, it fails in a way that saves the human time.&lt;/p>
&lt;h3 id="deploying-it">Deploying it&lt;/h3>
&lt;p>Flue runs in a few places. For something this small I&amp;rsquo;d put it on Cloudflare Workers. The request flow is &amp;ldquo;JIRA webhook, 30 seconds of model calls, JIRA REST API.&amp;rdquo; No long-running compute, no filesystem, just a function that eats a webhook.&lt;/p>
&lt;p>Point JIRA&amp;rsquo;s automation rule at your worker URL, configure secrets in Cloudflare for the JIRA token and the Anthropic key, push with &lt;code>wrangler deploy&lt;/code>. The whole thing has fewer moving parts than the legacy &amp;ldquo;triage Slack channel&amp;rdquo; my team used to run.&lt;/p>
&lt;h3 id="where-this-stops-working">Where this stops working&lt;/h3>
&lt;p>It is not a replacement for an EM. It is not a replacement for a tech lead with context. The agent is good at the volume case: routine tickets, clear ownership, well-documented domains. It will struggle at the long tail:&lt;/p>
&lt;ul>
&lt;li>Cross-team tickets that legitimately span two domains.&lt;/li>
&lt;li>Tickets where the customer is using the wrong vocabulary for the system.&lt;/li>
&lt;li>Anything political. Reorgs, ownership disputes, &amp;ldquo;you broke our launch.&amp;rdquo;&lt;/li>
&lt;/ul>
&lt;p>The escalation path is what catches those. As long as the agent is willing to stop and ask, the long tail goes to a human anyway. The agent&amp;rsquo;s job isn&amp;rsquo;t to be right about everything. It&amp;rsquo;s to clear the boring 70% so humans can focus on the interesting 30%.&lt;/p>
&lt;p>That&amp;rsquo;s the same pattern I keep finding for these harness-based agents in general. They don&amp;rsquo;t replace the senior person. They eat the toil that the senior person hates doing, and they hand back the genuinely hard cases with a useful first draft. On net, an excellent trade.&lt;/p></description></item><item><title>Flue vs Mastra: TypeScript Agents in 2026</title><link>https://houdeshell.dev/post/2026-05-02_flue-mastra-typescript-agents/</link><pubDate>Sat, 02 May 2026 09:00:00 -0400</pubDate><guid>https://houdeshell.dev/post/2026-05-02_flue-mastra-typescript-agents/</guid><description>&lt;p>I&amp;rsquo;ve been on a kick lately, building small agents in TypeScript instead of Python. Two frameworks keep showing up in my browser tabs: &lt;a href="https://flueframework.com/"target="_blank" rel="noopener noreferrer">Flue&lt;/a> and &lt;a href="https://mastra.ai/"target="_blank" rel="noopener noreferrer">Mastra&lt;/a>. They&amp;rsquo;re both betting on the same thing: &lt;strong>TypeScript is the right language to ship AI in&lt;/strong>. They just disagree about almost everything else.&lt;/p>
&lt;p>That disagreement is the interesting part.&lt;/p>
&lt;h3 id="the-harness-vs-the-workflow">The harness vs the workflow&lt;/h3>
&lt;p>Flue&amp;rsquo;s pitch is one line: &lt;code>Agent = Model + Harness&lt;/code>. The framework gives you a programmable harness. Sandbox, filesystem, bash, sessions, skills with structured output. You drop in a model. You get something that looks a lot like Claude Code or Codex, except &lt;em>you&lt;/em> wrote it and &lt;em>you&lt;/em> deploy it.&lt;/p>
&lt;p>Here&amp;rsquo;s a Flue agent that triages a GitHub issue:&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-typescript" data-lang="typescript">&lt;span class="line">&lt;span class="cl">&lt;span class="kr">import&lt;/span> &lt;span class="kr">type&lt;/span> &lt;span class="p">{&lt;/span> &lt;span class="nx">FlueContext&lt;/span> &lt;span class="p">}&lt;/span> &lt;span class="kr">from&lt;/span> &lt;span class="s1">&amp;#39;@flue/sdk/client&amp;#39;&lt;/span>&lt;span class="p">;&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="kr">import&lt;/span> &lt;span class="o">*&lt;/span> &lt;span class="kr">as&lt;/span> &lt;span class="nx">v&lt;/span> &lt;span class="kr">from&lt;/span> &lt;span class="s1">&amp;#39;valibot&amp;#39;&lt;/span>&lt;span class="p">;&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="kr">export&lt;/span> &lt;span class="k">default&lt;/span> &lt;span class="kr">async&lt;/span> &lt;span class="kd">function&lt;/span> &lt;span class="p">({&lt;/span> &lt;span class="nx">init&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="nx">payload&lt;/span> &lt;span class="p">}&lt;/span>&lt;span class="o">:&lt;/span> &lt;span class="nx">FlueContext&lt;/span>&lt;span class="p">)&lt;/span> &lt;span class="p">{&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="kr">const&lt;/span> &lt;span class="nx">agent&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="k">await&lt;/span> &lt;span class="nx">init&lt;/span>&lt;span class="p">({&lt;/span> &lt;span class="nx">model&lt;/span>&lt;span class="o">:&lt;/span> &lt;span class="s1">&amp;#39;anthropic/claude-opus-4-7&amp;#39;&lt;/span> &lt;span class="p">});&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="kr">const&lt;/span> &lt;span class="nx">session&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="k">await&lt;/span> &lt;span class="nx">agent&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">session&lt;/span>&lt;span class="p">();&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="kr">const&lt;/span> &lt;span class="nx">triage&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="k">await&lt;/span> &lt;span class="nx">session&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">skill&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="s1">&amp;#39;triage&amp;#39;&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="p">{&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="nx">args&lt;/span>&lt;span class="o">:&lt;/span> &lt;span class="p">{&lt;/span> &lt;span class="nx">issueNumber&lt;/span>: &lt;span class="kt">payload.issueNumber&lt;/span> &lt;span class="p">},&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="nx">result&lt;/span>: &lt;span class="kt">v.object&lt;/span>&lt;span class="p">({&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="nx">severity&lt;/span>: &lt;span class="kt">v.picklist&lt;/span>&lt;span class="p">([&lt;/span>&lt;span class="s1">&amp;#39;low&amp;#39;&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="s1">&amp;#39;medium&amp;#39;&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="s1">&amp;#39;high&amp;#39;&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="s1">&amp;#39;critical&amp;#39;&lt;/span>&lt;span class="p">]),&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="nx">reproducible&lt;/span>: &lt;span class="kt">v.boolean&lt;/span>&lt;span class="p">(),&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="nx">summary&lt;/span>: &lt;span class="kt">v.string&lt;/span>&lt;span class="p">(),&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="p">}),&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="p">});&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="k">return&lt;/span> &lt;span class="nx">triage&lt;/span>&lt;span class="p">;&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="p">}&lt;/span>
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>The whole shape is: init the agent, open a session, call a skill, get a typed result back. The skill is a directory of prompts and tools that the harness orchestrates. Your code never has to babysit a tool-call loop. The framework is doing it.&lt;/p>
&lt;p>Mastra goes the other way. The core abstractions are &lt;strong>agents&lt;/strong>, &lt;strong>workflows&lt;/strong>, and &lt;strong>RAG&lt;/strong>. You compose them, evaluate them, and deploy them as APIs next to your Next.js or Hono app.&lt;/p>
&lt;p>A roughly equivalent Mastra agent:&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-typescript" data-lang="typescript">&lt;span class="line">&lt;span class="cl">&lt;span class="kr">import&lt;/span> &lt;span class="p">{&lt;/span> &lt;span class="nx">Agent&lt;/span> &lt;span class="p">}&lt;/span> &lt;span class="kr">from&lt;/span> &lt;span class="s1">&amp;#39;@mastra/core&amp;#39;&lt;/span>&lt;span class="p">;&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="kr">import&lt;/span> &lt;span class="p">{&lt;/span> &lt;span class="nx">z&lt;/span> &lt;span class="p">}&lt;/span> &lt;span class="kr">from&lt;/span> &lt;span class="s1">&amp;#39;zod&amp;#39;&lt;/span>&lt;span class="p">;&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="kr">export&lt;/span> &lt;span class="kr">const&lt;/span> &lt;span class="nx">triageAgent&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="k">new&lt;/span> &lt;span class="nx">Agent&lt;/span>&lt;span class="p">({&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="nx">name&lt;/span>&lt;span class="o">:&lt;/span> &lt;span class="s1">&amp;#39;issue-triage&amp;#39;&lt;/span>&lt;span class="p">,&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="nx">instructions&lt;/span>&lt;span class="o">:&lt;/span> &lt;span class="s1">&amp;#39;You triage GitHub issues. Be concise.&amp;#39;&lt;/span>&lt;span class="p">,&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="nx">model&lt;/span>&lt;span class="o">:&lt;/span> &lt;span class="p">{&lt;/span> &lt;span class="nx">provider&lt;/span>&lt;span class="o">:&lt;/span> &lt;span class="s1">&amp;#39;ANTHROPIC&amp;#39;&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="nx">name&lt;/span>&lt;span class="o">:&lt;/span> &lt;span class="s1">&amp;#39;claude-opus-4-7&amp;#39;&lt;/span> &lt;span class="p">},&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="p">});&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="kr">export&lt;/span> &lt;span class="kr">async&lt;/span> &lt;span class="kd">function&lt;/span> &lt;span class="nx">triage&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="nx">issueNumber&lt;/span>: &lt;span class="kt">number&lt;/span>&lt;span class="p">)&lt;/span> &lt;span class="p">{&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="kr">const&lt;/span> &lt;span class="nx">result&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="k">await&lt;/span> &lt;span class="nx">triageAgent&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">generate&lt;/span>&lt;span class="p">(&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="sb">`Triage issue #&lt;/span>&lt;span class="si">${&lt;/span>&lt;span class="nx">issueNumber&lt;/span>&lt;span class="si">}&lt;/span>&lt;span class="sb">`&lt;/span>&lt;span class="p">,&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="p">{&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="nx">output&lt;/span>: &lt;span class="kt">z.object&lt;/span>&lt;span class="p">({&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="nx">severity&lt;/span>: &lt;span class="kt">z.enum&lt;/span>&lt;span class="p">([&lt;/span>&lt;span class="s1">&amp;#39;low&amp;#39;&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="s1">&amp;#39;medium&amp;#39;&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="s1">&amp;#39;high&amp;#39;&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="s1">&amp;#39;critical&amp;#39;&lt;/span>&lt;span class="p">]),&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="nx">reproducible&lt;/span>: &lt;span class="kt">z.boolean&lt;/span>&lt;span class="p">(),&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="nx">summary&lt;/span>: &lt;span class="kt">z.string&lt;/span>&lt;span class="p">(),&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="p">}),&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="p">},&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="p">);&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="k">return&lt;/span> &lt;span class="nx">result&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="kt">object&lt;/span>&lt;span class="p">;&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="p">}&lt;/span>
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>Different shape entirely. You declare the agent up front, you call &lt;code>.generate()&lt;/code> with a structured output schema, you get an object back. There&amp;rsquo;s no harness loop driving sessions and skills. There&amp;rsquo;s just a model with tools, and you wire it into your app like any other service.&lt;/p>
&lt;h3 id="where-they-diverge">Where they diverge&lt;/h3>
&lt;table>
&lt;thead>
&lt;tr>
&lt;th>&lt;/th>
&lt;th>Flue&lt;/th>
&lt;th>Mastra&lt;/th>
&lt;/tr>
&lt;/thead>
&lt;tbody>
&lt;tr>
&lt;td>Mental model&lt;/td>
&lt;td>Agent = Model + Harness&lt;/td>
&lt;td>Agent = LLM with tools&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Primitives&lt;/td>
&lt;td>Sessions, skills, sandboxes&lt;/td>
&lt;td>Agents, workflows, RAG&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>What you bring&lt;/td>
&lt;td>Skill prompts and tools&lt;/td>
&lt;td>Instructions, tools, eval suites&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Deploy targets&lt;/td>
&lt;td>Node, Cloudflare Workers, GitHub Actions&lt;/td>
&lt;td>Anywhere TypeScript runs, plus Mastra Cloud&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Replaces&lt;/td>
&lt;td>Dosu, Greptile, CodeRabbit&lt;/td>
&lt;td>Honestly, half of LangChain&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Tagline&lt;/td>
&lt;td>&amp;ldquo;Stop renting someone else&amp;rsquo;s agent&amp;rdquo;&lt;/td>
&lt;td>&amp;ldquo;Python trains, TypeScript ships&amp;rdquo;&lt;/td>
&lt;/tr>
&lt;/tbody>
&lt;/table>
&lt;p>If you&amp;rsquo;re building something that &lt;em>acts&lt;/em> (writes files, runs commands, reads a repo, works through a task across many tool calls), Flue&amp;rsquo;s harness model is doing more for you. If you&amp;rsquo;re embedding an agent into an existing app and the bigger problem is glue, eval, and observability, Mastra has more of the production scaffolding ready to go.&lt;/p>
&lt;p>I don&amp;rsquo;t think one is winning yet. They might both be right for different jobs.&lt;/p>
&lt;h3 id="the-thing-nobodys-saying-out-loud">The thing nobody&amp;rsquo;s saying out loud&lt;/h3>
&lt;p>Both of these projects assume something I would have laughed at five years ago: that TypeScript is a serious language for building AI infrastructure. Not a frontend duct tape. Not a glue layer. The actual substrate.&lt;/p>
&lt;p>A couple weeks back, &lt;a href="https://devblogs.microsoft.com/typescript/announcing-typescript-7-0-beta/"target="_blank" rel="noopener noreferrer">Microsoft shipped the TypeScript 7.0 beta&lt;/a>, and the punchline is that the compiler is no longer written in TypeScript. It&amp;rsquo;s written in Go. Native code, shared memory parallelism, the whole thing. They&amp;rsquo;re claiming &lt;strong>about 10x faster&lt;/strong> than 6.0 on real codebases. The language server, too. The editing experience is reportedly night and day.&lt;/p>
&lt;p>Read that twice. The TypeScript team looked at the language they invented, decided it wasn&amp;rsquo;t fast enough to compile itself, and rewrote the toolchain in Go. That&amp;rsquo;s a remarkable thing to do.&lt;/p>
&lt;p>It also reframes everything. If &lt;code>tsc&lt;/code> is fast enough that incremental compiles feel like Go&amp;rsquo;s build times, the cost-of-iteration argument against TypeScript for serious systems work mostly evaporates. The other arguments (no real concurrency, GC pauses, V8 quirks) are runtime arguments, and most agent code is I/O bound on a model API anyway.&lt;/p>
&lt;p>So you get a typed, npm-friendly, JIT&amp;rsquo;d runtime with a now-fast toolchain, and the LLM ecosystem has decided to publish first-class SDKs for it. That&amp;rsquo;s a pretty good place for a framework to land.&lt;/p>
&lt;p>Flue and Mastra are both betting on it. They might both be right.&lt;/p>
&lt;h3 id="what-id-actually-pick">What I&amp;rsquo;d actually pick&lt;/h3>
&lt;p>For a coding agent, an issue triage bot, anything that needs to &lt;em>do&lt;/em> work in a sandbox: Flue. The harness model is the right level of abstraction for that kind of work, and &amp;ldquo;drop your code in a Cloudflare Worker, point a webhook at it&amp;rdquo; is a stupidly nice deployment story.&lt;/p>
&lt;p>For a chat-shaped assistant living inside a real product, with eval suites and traces and the whole observability story: Mastra. The framework gets out of your way and the tooling around it is more mature.&lt;/p>
&lt;p>For everything else: keep an eye on both. The TypeScript-as-AI-substrate bet is going to play out fast, and a 10x faster compiler landing the same year is not a coincidence I would have predicted.&lt;/p></description></item><item><title>SFTPGo on Kubernetes: Azure Blob Without the $219/Month SFTP Tax</title><link>https://houdeshell.dev/post/2026-04-11_sftpgo-k8s-azure-blob/</link><pubDate>Sat, 11 Apr 2026 00:00:00 +0000</pubDate><guid>https://houdeshell.dev/post/2026-04-11_sftpgo-k8s-azure-blob/</guid><description>&lt;p>Someone needed SFTP access to Azure Blob Storage. Simple enough, right? Azure even has native SFTP support built into storage accounts now. Problem solved.&lt;/p>
&lt;p>Until you look at the price tag.&lt;/p>
&lt;h3 id="the-azure-sftp-tax">The Azure SFTP Tax&lt;/h3>
&lt;p>Azure&amp;rsquo;s native SFTP support for Blob Storage costs &lt;strong>$0.30 per hour&lt;/strong>. That&amp;rsquo;s roughly &lt;strong>$219 per month&lt;/strong>. Just for the endpoint. Just for it to &lt;em>exist&lt;/em>. You haven&amp;rsquo;t transferred a single file yet. That&amp;rsquo;s before storage costs, before transaction costs, before anything useful happens.&lt;/p>
&lt;p>Oh, and it requires &lt;strong>hierarchical namespace&lt;/strong> (Azure Data Lake Storage Gen2), which means you can&amp;rsquo;t just flip it on for an existing standard Blob Storage account. You need ADLS Gen2 from the start, or you&amp;rsquo;re creating a new storage account.&lt;/p>
&lt;p>For a high-throughput enterprise workload moving terabytes a day, $219/month is a rounding error. For the rest of us who just need to let a vendor or partner drop some files into a blob container? That&amp;rsquo;s an absurd premium for what amounts to a protocol adapter.&lt;/p>
&lt;h3 id="sftpgo-the-alternative">SFTPGo: The Alternative&lt;/h3>
&lt;p>&lt;a href="https://github.com/drakkan/sftpgo"target="_blank" rel="noopener noreferrer">SFTPGo&lt;/a> is an open-source, fully featured SFTP server written in Go. It supports virtual folders backed by local filesystem, S3, Google Cloud Storage, and Azure Blob Storage. It handles SSH keys, password auth, per-user quotas, bandwidth limits, and a web admin UI. It&amp;rsquo;s also a single binary that runs great in a container.&lt;/p>
&lt;p>The plan: run SFTPGo on Kubernetes, point virtual folders at Azure Blob Storage, and skip the $219/month endpoint tax entirely.&lt;/p>
&lt;h3 id="the-kubernetes-setup">The Kubernetes Setup&lt;/h3>
&lt;p>Here&amp;rsquo;s what the deployment looks like. Nothing exotic. A Deployment, a couple of Services, a ConfigMap, some Secrets, and a PostgreSQL database for SFTPGo&amp;rsquo;s user/config store.&lt;/p>
&lt;h4 id="namespace">Namespace&lt;/h4>
&lt;p>Keep it tidy:&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-yaml" data-lang="yaml">&lt;span class="line">&lt;span class="cl">&lt;span class="nt">apiVersion&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">v1&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">&lt;/span>&lt;span class="nt">kind&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">Namespace&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">&lt;/span>&lt;span class="nt">metadata&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">name&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">sftpgo&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;h4 id="the-secrets">The Secrets&lt;/h4>
&lt;p>Three secrets. Azure Blob credentials, PostgreSQL connection, and SSH host keys.&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-yaml" data-lang="yaml">&lt;span class="line">&lt;span class="cl">&lt;span class="nt">apiVersion&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">v1&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">&lt;/span>&lt;span class="nt">kind&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">Secret&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">&lt;/span>&lt;span class="nt">metadata&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">name&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">sftpgo-azure&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">namespace&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">sftpgo&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">&lt;/span>&lt;span class="nt">type&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">Opaque&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">&lt;/span>&lt;span class="nt">stringData&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">SFTPGO_AZBLOB_CONTAINER&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="s2">&amp;#34;your-container-name&amp;#34;&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">SFTPGO_AZBLOB_ACCOUNT_NAME&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="s2">&amp;#34;yourstorageaccount&amp;#34;&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">SFTPGO_AZBLOB_ACCOUNT_KEY&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="s2">&amp;#34;your-storage-account-key&amp;#34;&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">&lt;/span>&lt;span class="nn">---&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">&lt;/span>&lt;span class="nt">apiVersion&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">v1&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">&lt;/span>&lt;span class="nt">kind&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">Secret&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">&lt;/span>&lt;span class="nt">metadata&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">name&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">sftpgo-db&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">namespace&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">sftpgo&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">&lt;/span>&lt;span class="nt">type&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">Opaque&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">&lt;/span>&lt;span class="nt">stringData&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">SFTPGO_DATA_PROVIDER__DRIVER&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="s2">&amp;#34;postgresql&amp;#34;&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">SFTPGO_DATA_PROVIDER__NAME&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="s2">&amp;#34;postgresql://sftpgo:your-db-password@sftpgo-db:5432/sftpgo?sslmode=disable&amp;#34;&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>If you&amp;rsquo;re using Managed Identity for the Azure side (and you should be if you can), you can skip the account key and rely on the pod&amp;rsquo;s identity. But that&amp;rsquo;s a topic for another post.&lt;/p>
&lt;p>SFTPGo reads its data provider config from environment variables using the &lt;code>SFTPGO_DATA_PROVIDER__&lt;/code> prefix. The double underscore maps to the nested JSON structure. Neat trick that saves you from templating JSON.&lt;/p>
&lt;p>The host keys get their own secret. Generate them once, store them in the cluster, and every pod (current and future replicas) uses the same keys. If you skip this step and let SFTPGo generate keys on startup, every pod restart hands clients a different fingerprint. That &lt;code>WARNING: REMOTE HOST IDENTIFICATION HAS CHANGED!&lt;/code> message trains users to blindly accept key changes, which is the opposite of what SSH security is for.&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-bash" data-lang="bash">&lt;span class="line">&lt;span class="cl">&lt;span class="c1"># Generate the keys locally&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">ssh-keygen -t ed25519 -f id_ed25519 -N &lt;span class="s2">&amp;#34;&amp;#34;&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">ssh-keygen -t rsa -b &lt;span class="m">4096&lt;/span> -f id_rsa -N &lt;span class="s2">&amp;#34;&amp;#34;&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="c1"># Create the secret&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">kubectl create secret generic sftpgo-hostkeys -n sftpgo &lt;span class="se">\
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="se">&lt;/span> --from-file&lt;span class="o">=&lt;/span>id_ed25519 &lt;span class="se">\
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="se">&lt;/span> --from-file&lt;span class="o">=&lt;/span>id_rsa
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="c1"># Clean up local copies&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">rm id_ed25519 id_ed25519.pub id_rsa id_rsa.pub
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;h4 id="the-configmap">The ConfigMap&lt;/h4>
&lt;p>SFTPGo has a mountain of configuration options. The data provider config is handled by the env vars in the secret above, so the ConfigMap just needs the SFTP, HTTP, and telemetry bindings:&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-yaml" data-lang="yaml">&lt;span class="line">&lt;span class="cl">&lt;span class="nt">apiVersion&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">v1&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">&lt;/span>&lt;span class="nt">kind&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">ConfigMap&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">&lt;/span>&lt;span class="nt">metadata&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">name&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">sftpgo-config&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">namespace&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">sftpgo&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">&lt;/span>&lt;span class="nt">data&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">sftpgo.json&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="p">|&lt;/span>&lt;span class="sd">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="sd"> {
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="sd"> &amp;#34;sftpd&amp;#34;: {
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="sd"> &amp;#34;bindings&amp;#34;: [{ &amp;#34;port&amp;#34;: 2022, &amp;#34;address&amp;#34;: &amp;#34;&amp;#34; }],
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="sd"> &amp;#34;host_keys&amp;#34;: [
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="sd"> &amp;#34;/var/lib/sftpgo/id_ed25519&amp;#34;,
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="sd"> &amp;#34;/var/lib/sftpgo/id_rsa&amp;#34;
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="sd"> ]
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="sd"> },
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="sd"> &amp;#34;httpd&amp;#34;: {
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="sd"> &amp;#34;bindings&amp;#34;: [{
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="sd"> &amp;#34;port&amp;#34;: 8080,
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="sd"> &amp;#34;address&amp;#34;: &amp;#34;&amp;#34;,
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="sd"> &amp;#34;enable_web_admin&amp;#34;: true,
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="sd"> &amp;#34;enable_web_client&amp;#34;: true
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="sd"> }]
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="sd"> },
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="sd"> &amp;#34;telemetry&amp;#34;: {
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="sd"> &amp;#34;bind_port&amp;#34;: 10000,
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="sd"> &amp;#34;bind_address&amp;#34;: &amp;#34;&amp;#34;,
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="sd"> &amp;#34;enable_profiler&amp;#34;: false,
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="sd"> &amp;#34;auth_user_file&amp;#34;: &amp;#34;&amp;#34;
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="sd"> }
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="sd"> }&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>Port &lt;code>2022&lt;/code> for SFTP, port &lt;code>8080&lt;/code> for the web admin, port &lt;code>10000&lt;/code> for the Prometheus metrics endpoint. PostgreSQL connection details come from the &lt;code>sftpgo-db&lt;/code> secret, so they stay out of the ConfigMap where they belong.&lt;/p>
&lt;h4 id="postgresql">PostgreSQL&lt;/h4>
&lt;p>SFTPGo needs a database for users, folders, and configuration. PostgreSQL is the right choice here. It handles concurrent access without drama, and if you ever want to scale SFTPGo beyond a single replica, you&amp;rsquo;ll need it.&lt;/p>
&lt;p>A simple StatefulSet gets the job done:&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-yaml" data-lang="yaml">&lt;span class="line">&lt;span class="cl">&lt;span class="nt">apiVersion&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">v1&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">&lt;/span>&lt;span class="nt">kind&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">Secret&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">&lt;/span>&lt;span class="nt">metadata&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">name&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">sftpgo-pg&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">namespace&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">sftpgo&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">&lt;/span>&lt;span class="nt">type&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">Opaque&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">&lt;/span>&lt;span class="nt">stringData&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">POSTGRES_USER&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="s2">&amp;#34;sftpgo&amp;#34;&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">POSTGRES_PASSWORD&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="s2">&amp;#34;your-db-password&amp;#34;&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">POSTGRES_DB&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="s2">&amp;#34;sftpgo&amp;#34;&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">&lt;/span>&lt;span class="nn">---&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">&lt;/span>&lt;span class="nt">apiVersion&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">apps/v1&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">&lt;/span>&lt;span class="nt">kind&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">StatefulSet&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">&lt;/span>&lt;span class="nt">metadata&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">name&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">sftpgo-db&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">namespace&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">sftpgo&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">&lt;/span>&lt;span class="nt">spec&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">serviceName&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">sftpgo-db&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">replicas&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="m">1&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">selector&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">matchLabels&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">app&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">sftpgo-db&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">template&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">metadata&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">labels&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">app&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">sftpgo-db&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">spec&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">containers&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>- &lt;span class="nt">name&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">postgres&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">image&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">postgres:16-alpine&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">ports&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>- &lt;span class="nt">containerPort&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="m">5432&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">envFrom&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>- &lt;span class="nt">secretRef&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">name&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">sftpgo-pg&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">volumeMounts&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>- &lt;span class="nt">name&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">pgdata&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">mountPath&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">/var/lib/postgresql/data&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">resources&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">requests&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">cpu&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">100m&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">memory&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">256Mi&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">limits&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">cpu&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">500m&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">memory&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">512Mi&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">volumeClaimTemplates&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>- &lt;span class="nt">metadata&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">name&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">pgdata&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">spec&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">accessModes&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="p">[&lt;/span>&lt;span class="s2">&amp;#34;ReadWriteOnce&amp;#34;&lt;/span>&lt;span class="p">]&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">resources&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">requests&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">storage&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">5Gi&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">&lt;/span>&lt;span class="nn">---&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">&lt;/span>&lt;span class="nt">apiVersion&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">v1&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">&lt;/span>&lt;span class="nt">kind&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">Service&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">&lt;/span>&lt;span class="nt">metadata&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">name&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">sftpgo-db&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">namespace&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">sftpgo&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">&lt;/span>&lt;span class="nt">spec&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">clusterIP&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">None&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">selector&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">app&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">sftpgo-db&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">ports&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>- &lt;span class="nt">port&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="m">5432&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">targetPort&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="m">5432&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>A headless Service gives SFTPGo a stable &lt;code>sftpgo-db:5432&lt;/code> endpoint to connect to. The StatefulSet&amp;rsquo;s &lt;code>volumeClaimTemplates&lt;/code> handle persistent storage so your data survives pod restarts.&lt;/p>
&lt;p>If you&amp;rsquo;re already running a managed PostgreSQL instance (Azure Database for PostgreSQL, RDS, etc.), just point the connection string at that and skip this entirely.&lt;/p>
&lt;h4 id="the-deployment">The Deployment&lt;/h4>
&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-yaml" data-lang="yaml">&lt;span class="line">&lt;span class="cl">&lt;span class="nt">apiVersion&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">apps/v1&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">&lt;/span>&lt;span class="nt">kind&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">Deployment&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">&lt;/span>&lt;span class="nt">metadata&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">name&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">sftpgo&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">namespace&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">sftpgo&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">labels&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">app&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">sftpgo&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">&lt;/span>&lt;span class="nt">spec&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">replicas&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="m">1&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">selector&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">matchLabels&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">app&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">sftpgo&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">template&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">metadata&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">labels&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">app&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">sftpgo&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">spec&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">containers&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>- &lt;span class="nt">name&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">sftpgo&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">image&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">drakkan/sftpgo:latest&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">ports&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>- &lt;span class="nt">containerPort&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="m">2022&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">name&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">sftp&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>- &lt;span class="nt">containerPort&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="m">8080&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">name&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">http&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>- &lt;span class="nt">containerPort&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="m">10000&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">name&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">metrics&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">envFrom&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>- &lt;span class="nt">secretRef&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">name&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">sftpgo-azure&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>- &lt;span class="nt">secretRef&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">name&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">sftpgo-db&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">volumeMounts&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>- &lt;span class="nt">name&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">config&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">mountPath&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">/etc/sftpgo/sftpgo.json&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">subPath&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">sftpgo.json&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>- &lt;span class="nt">name&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">hostkeys&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">mountPath&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">/var/lib/sftpgo/id_ed25519&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">subPath&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">id_ed25519&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">readOnly&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="kc">true&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>- &lt;span class="nt">name&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">hostkeys&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">mountPath&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">/var/lib/sftpgo/id_rsa&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">subPath&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">id_rsa&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">readOnly&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="kc">true&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">resources&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">requests&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">cpu&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">100m&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">memory&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">128Mi&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">limits&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">cpu&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">500m&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">memory&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">256Mi&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">volumes&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>- &lt;span class="nt">name&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">config&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">configMap&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">name&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">sftpgo-config&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>- &lt;span class="nt">name&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">hostkeys&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">secret&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">secretName&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">sftpgo-hostkeys&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">defaultMode&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="m">0600&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>No PVC needed. Config comes from a ConfigMap, credentials from Secrets, host keys from a Secret, and user data lives in PostgreSQL. The Deployment is completely stateless, which means Kubernetes can reschedule pods freely and scaling replicas is trivial (more on that below).&lt;/p>
&lt;h4 id="the-service">The Service&lt;/h4>
&lt;p>The Service is internal only. ClusterIP, not LoadBalancer. We don&amp;rsquo;t want the admin UI anywhere near the public internet.&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-yaml" data-lang="yaml">&lt;span class="line">&lt;span class="cl">&lt;span class="nt">apiVersion&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">v1&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">&lt;/span>&lt;span class="nt">kind&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">Service&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">&lt;/span>&lt;span class="nt">metadata&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">name&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">sftpgo&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">namespace&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">sftpgo&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">&lt;/span>&lt;span class="nt">spec&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">type&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">ClusterIP&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">selector&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">app&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">sftpgo&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">ports&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>- &lt;span class="nt">name&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">sftp&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">port&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="m">2022&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">targetPort&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="m">2022&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">protocol&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">TCP&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>- &lt;span class="nt">name&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">http&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">port&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="m">8080&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">targetPort&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="m">8080&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">protocol&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">TCP&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>- &lt;span class="nt">name&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">metrics&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">port&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="m">10000&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">targetPort&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="m">10000&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">protocol&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">TCP&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>To access the admin UI, use &lt;code>kubectl port-forward&lt;/code>:&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-bash" data-lang="bash">&lt;span class="line">&lt;span class="cl">kubectl port-forward -n sftpgo svc/sftpgo 8080:8080
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>Then open &lt;code>http://localhost:8080/web/admin&lt;/code> in your browser. This keeps the admin UI off the network entirely. No Ingress, no TLS cert to manage, no authentication proxy to configure. Just a direct tunnel from your machine to the pod when you need it.&lt;/p>
&lt;h4 id="the-ingress">The Ingress&lt;/h4>
&lt;p>SFTP needs to be reachable from the outside, so we expose &lt;em>only&lt;/em> the SFTP port. Most ingress controllers support TCP/UDP proxying alongside HTTP. With nginx-ingress, you configure it through a TCP services ConfigMap:&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-yaml" data-lang="yaml">&lt;span class="line">&lt;span class="cl">&lt;span class="nt">apiVersion&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">v1&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">&lt;/span>&lt;span class="nt">kind&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">ConfigMap&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">&lt;/span>&lt;span class="nt">metadata&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">name&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">tcp-services&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">namespace&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">ingress-nginx&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">&lt;/span>&lt;span class="nt">data&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">&amp;#34;2222&amp;#34;: &lt;/span>&lt;span class="s2">&amp;#34;sftpgo/sftpgo:2022&amp;#34;&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>This tells the ingress controller to listen on port &lt;code>2222&lt;/code> externally and forward TCP traffic to &lt;code>sftpgo:2022&lt;/code> in the &lt;code>sftpgo&lt;/code> namespace. Make sure your ingress controller&amp;rsquo;s Service and Deployment are configured to expose this port. You&amp;rsquo;ll need to add it to the controller&amp;rsquo;s &lt;code>--tcp-services-configmap&lt;/code> arg and open the port on the controller&amp;rsquo;s Service:&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-yaml" data-lang="yaml">&lt;span class="line">&lt;span class="cl">&lt;span class="c"># Patch the ingress-nginx controller Service to expose port 2222&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">&lt;/span>&lt;span class="c"># (or add it to your existing Service definition)&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">&lt;/span>&lt;span class="nt">spec&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">ports&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>- &lt;span class="nt">name&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">sftp-proxy&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">port&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="m">2222&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">targetPort&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="m">2222&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">protocol&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">TCP&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>Your clients connect with &lt;code>sftp -P 2222 user@your-ingress-ip&lt;/code>. The admin UI stays completely internal. The only thing exposed to the internet is the SFTP port, which is exactly the attack surface you want: SSH protocol with key-based auth. Not a web UI with a login form.&lt;/p>
&lt;h3 id="configuring-the-azure-blob-virtual-folder">Configuring the Azure Blob Virtual Folder&lt;/h3>
&lt;p>Once everything is running, port-forward into the admin UI and create a user:&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-bash" data-lang="bash">&lt;span class="line">&lt;span class="cl">kubectl port-forward -n sftpgo svc/sftpgo 8080:8080
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>Open &lt;code>http://localhost:8080/web/admin&lt;/code>. The default admin credentials are in the SFTPGo docs (change them immediately, obviously).&lt;/p>
&lt;p>The fun part: virtual folders. When you create or edit a user, you can map a virtual path to an Azure Blob container. In the SFTPGo admin:&lt;/p>
&lt;ol>
&lt;li>Go to your user&amp;rsquo;s settings&lt;/li>
&lt;li>Under &lt;strong>Virtual Folders&lt;/strong>, add a new folder&lt;/li>
&lt;li>Set the &lt;strong>mapped path&lt;/strong> (e.g., &lt;code>/uploads&lt;/code>)&lt;/li>
&lt;li>Choose &lt;strong>Azure Blob Storage&lt;/strong> as the filesystem&lt;/li>
&lt;li>Fill in the container name and credentials (or let the env vars handle it)&lt;/li>
&lt;/ol>
&lt;p>Now when a client connects via SFTP and &lt;code>cd /uploads&lt;/code>, they&amp;rsquo;re reading and writing directly to Azure Blob Storage. The client has no idea. They just see files and directories.&lt;/p>
&lt;p>You can also do this via the REST API if you&amp;rsquo;re automating user provisioning:&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-bash" data-lang="bash">&lt;span class="line">&lt;span class="cl">curl -X POST http://localhost:8080/api/v2/users &lt;span class="se">\
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="se">&lt;/span> -H &lt;span class="s2">&amp;#34;Content-Type: application/json&amp;#34;&lt;/span> &lt;span class="se">\
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="se">&lt;/span> -u admin:password &lt;span class="se">\
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="se">&lt;/span> -d &lt;span class="s1">&amp;#39;{
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="s1"> &amp;#34;username&amp;#34;: &amp;#34;vendor-uploads&amp;#34;,
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="s1"> &amp;#34;password&amp;#34;: &amp;#34;a-strong-password-please&amp;#34;,
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="s1"> &amp;#34;permissions&amp;#34;: { &amp;#34;/&amp;#34;: [&amp;#34;*&amp;#34;] },
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="s1"> &amp;#34;virtual_folders&amp;#34;: [
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="s1"> {
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="s1"> &amp;#34;name&amp;#34;: &amp;#34;azure-uploads&amp;#34;,
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="s1"> &amp;#34;mapped_path&amp;#34;: &amp;#34;/uploads&amp;#34;,
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="s1"> &amp;#34;virtual_path&amp;#34;: &amp;#34;/uploads&amp;#34;,
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="s1"> &amp;#34;filesystem&amp;#34;: {
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="s1"> &amp;#34;provider&amp;#34;: 3,
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="s1"> &amp;#34;azblobconfig&amp;#34;: {
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="s1"> &amp;#34;container&amp;#34;: &amp;#34;vendor-data&amp;#34;,
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="s1"> &amp;#34;account_name&amp;#34;: &amp;#34;yourstorageaccount&amp;#34;,
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="s1"> &amp;#34;account_key&amp;#34;: { &amp;#34;payload&amp;#34;: &amp;#34;base64-key-here&amp;#34; }
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="s1"> }
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="s1"> }
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="s1"> }
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="s1"> ]
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="s1"> }&amp;#39;&lt;/span>
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>Provider &lt;code>3&lt;/code> is Azure Blob Storage in SFTPGo&amp;rsquo;s config. Provider &lt;code>1&lt;/code> is S3, &lt;code>2&lt;/code> is Google Cloud Storage, &lt;code>0&lt;/code> is local. The kind of magic number situation that makes you love (and occasionally curse) open source software.&lt;/p>
&lt;h3 id="what-you-get">What You Get&lt;/h3>
&lt;p>For the cost of a small pod running on your existing cluster, you now have:&lt;/p>
&lt;ul>
&lt;li>&lt;strong>SFTP access to Azure Blob Storage&lt;/strong> without the $219/month Azure tax&lt;/li>
&lt;li>&lt;strong>Per-user access control&lt;/strong> with SSH keys or passwords&lt;/li>
&lt;li>&lt;strong>Virtual folder mappings&lt;/strong> so different users see different containers or prefixes&lt;/li>
&lt;li>&lt;strong>Bandwidth throttling and quotas&lt;/strong> per user&lt;/li>
&lt;li>&lt;strong>Audit logging&lt;/strong> of every connection and file operation&lt;/li>
&lt;li>&lt;strong>A web UI&lt;/strong> for managing users via &lt;code>kubectl port-forward&lt;/code>&lt;/li>
&lt;/ul>
&lt;h3 id="scaling-up-multiple-replicas">Scaling Up: Multiple Replicas&lt;/h3>
&lt;p>A single SFTPGo pod works fine until it doesn&amp;rsquo;t. Maybe you need high availability. Maybe you have enough concurrent connections that one pod is sweating. Maybe you just don&amp;rsquo;t want a single point of failure sitting between your vendors and their file drops.&lt;/p>
&lt;p>The good news: because we set this up with PostgreSQL for state and Secrets for host keys, the Deployment is already stateless. Scaling is just:&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-yaml" data-lang="yaml">&lt;span class="line">&lt;span class="cl">&lt;span class="nt">spec&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">replicas&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="m">3&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>That&amp;rsquo;s it. No migration, no rearchitecture. Every replica mounts the same host keys from the &lt;code>sftpgo-hostkeys&lt;/code> Secret, so clients get a consistent SSH fingerprint regardless of which pod they land on. User data, virtual folder configs, and quota tracking all live in PostgreSQL, so every replica sees the same state.&lt;/p>
&lt;p>SFTPGo also supports a shared event system through the data provider. When one instance updates a user, other instances pick up the change. No manual cache invalidation, no restart required.&lt;/p>
&lt;h4 id="load-distribution">Load Distribution&lt;/h4>
&lt;p>SFTP is a TCP protocol, so Kubernetes&amp;rsquo; default round-robin Service routing handles connection distribution automatically. Each new SFTP connection lands on a different pod. Connections are sticky for their duration (TCP, after all), so a long-running transfer won&amp;rsquo;t bounce between pods mid-stream.&lt;/p>
&lt;p>If you&amp;rsquo;re using the nginx-ingress TCP proxy from earlier, the same applies there. Each new inbound connection on port 2222 gets routed to the ClusterIP Service, which distributes across replicas.&lt;/p>
&lt;p>One thing to note: SFTP connections are long-lived. CPU and memory might look low even under heavy transfer load because the bottleneck is usually network I/O, not compute. If you want autoscaling, you&amp;rsquo;ll need custom metrics rather than CPU. The monitoring section below covers exactly how to set that up.&lt;/p>
&lt;h3 id="monitoring-with-prometheus-and-grafana">Monitoring with Prometheus and Grafana&lt;/h3>
&lt;p>Running an SFTP server without monitoring is like deploying to production without logs. You technically &lt;em>can&lt;/em>, but the first time a vendor calls asking why their upload failed three hours ago, you&amp;rsquo;ll wish you hadn&amp;rsquo;t.&lt;/p>
&lt;p>SFTPGo has a built-in Prometheus metrics endpoint. We already enabled it in the config on port &lt;code>10000&lt;/code>. Hit &lt;code>/metrics&lt;/code> on that port and you get the standard Prometheus exposition format with everything you&amp;rsquo;d want to know: active connections, bytes transferred, upload/download counts, error rates, and data provider health.&lt;/p>
&lt;h4 id="servicemonitor">ServiceMonitor&lt;/h4>
&lt;p>If you&amp;rsquo;re running the &lt;a href="https://github.com/prometheus-operator/prometheus-operator"target="_blank" rel="noopener noreferrer">Prometheus Operator&lt;/a> (and if you&amp;rsquo;re on Kubernetes with Prometheus, you probably are), a ServiceMonitor makes scrape configuration automatic:&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-yaml" data-lang="yaml">&lt;span class="line">&lt;span class="cl">&lt;span class="nt">apiVersion&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">monitoring.coreos.com/v1&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">&lt;/span>&lt;span class="nt">kind&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">ServiceMonitor&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">&lt;/span>&lt;span class="nt">metadata&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">name&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">sftpgo&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">namespace&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">sftpgo&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">labels&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">app&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">sftpgo&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">&lt;/span>&lt;span class="nt">spec&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">selector&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">matchLabels&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">app&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">sftpgo&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">endpoints&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>- &lt;span class="nt">port&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">metrics&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">interval&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">30s&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">path&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">/metrics&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>That&amp;rsquo;s the whole thing. The Prometheus Operator picks this up, finds the &lt;code>sftpgo&lt;/code> Service&amp;rsquo;s &lt;code>metrics&lt;/code> port, and starts scraping. No need to edit Prometheus config files or restart anything.&lt;/p>
&lt;p>If you&amp;rsquo;re running vanilla Prometheus without the Operator, add a scrape job to your &lt;code>prometheus.yml&lt;/code>:&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-yaml" data-lang="yaml">&lt;span class="line">&lt;span class="cl">&lt;span class="nt">scrape_configs&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>- &lt;span class="nt">job_name&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="s2">&amp;#34;sftpgo&amp;#34;&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">kubernetes_sd_configs&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>- &lt;span class="nt">role&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">endpoints&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">namespaces&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">names&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>- &lt;span class="l">sftpgo&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">relabel_configs&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>- &lt;span class="nt">source_labels&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="p">[&lt;/span>&lt;span class="l">__meta_kubernetes_endpoint_port_name]&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">action&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">keep&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">regex&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">metrics&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;h4 id="what-gets-exposed">What Gets Exposed&lt;/h4>
&lt;p>SFTPGo&amp;rsquo;s &lt;code>/metrics&lt;/code> endpoint exports a solid set of counters and gauges. Some of the useful ones:&lt;/p>
&lt;ul>
&lt;li>&lt;code>sftpgo_active_connections&lt;/code> - current active SFTP connections per pod&lt;/li>
&lt;li>&lt;code>sftpgo_active_transfers&lt;/code> - in-progress file transfers&lt;/li>
&lt;li>&lt;code>sftpgo_bytes_sent_total&lt;/code> / &lt;code>sftpgo_bytes_received_total&lt;/code> - cumulative transfer volume&lt;/li>
&lt;li>&lt;code>sftpgo_uploads_total&lt;/code> / &lt;code>sftpgo_downloads_total&lt;/code> - file operation counts&lt;/li>
&lt;li>&lt;code>sftpgo_uploads_errors_total&lt;/code> / &lt;code>sftpgo_downloads_errors_total&lt;/code> - failed transfers&lt;/li>
&lt;li>&lt;code>sftpgo_data_provider_availability&lt;/code> - whether the PostgreSQL connection is healthy&lt;/li>
&lt;/ul>
&lt;p>That last one is important. If the data provider goes down, SFTPGo can&amp;rsquo;t authenticate users. You want to know about that before your vendors do.&lt;/p>
&lt;h4 id="grafana-dashboard">Grafana Dashboard&lt;/h4>
&lt;p>SFTPGo ships a pre-built Grafana dashboard that you can import directly. Grab it from the &lt;a href="https://github.com/drakkan/sftpgo"target="_blank" rel="noopener noreferrer">SFTPGo repo&lt;/a> or import dashboard ID &lt;code>16498&lt;/code> from Grafana&amp;rsquo;s dashboard marketplace.&lt;/p>
&lt;p>If you&amp;rsquo;d rather build your own, here are a few PromQL queries worth pinning:&lt;/p>
&lt;p>&lt;strong>Active connections across all replicas:&lt;/strong>&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-promql" data-lang="promql">&lt;span class="line">&lt;span class="cl">&lt;span class="k">sum&lt;/span>&lt;span class="o">(&lt;/span>&lt;span class="nv">sftpgo_active_connections&lt;/span>&lt;span class="o">)&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>&lt;strong>Transfer throughput (bytes/sec, 5-minute rate):&lt;/strong>&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-promql" data-lang="promql">&lt;span class="line">&lt;span class="cl">&lt;span class="k">sum&lt;/span>&lt;span class="o">(&lt;/span>&lt;span class="kr">rate&lt;/span>&lt;span class="o">(&lt;/span>&lt;span class="nv">sftpgo_bytes_sent_total&lt;/span>&lt;span class="p">[&lt;/span>&lt;span class="s">5m&lt;/span>&lt;span class="p">]&lt;/span>&lt;span class="o">)&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="o">+&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="kr">rate&lt;/span>&lt;span class="o">(&lt;/span>&lt;span class="nv">sftpgo_bytes_received_total&lt;/span>&lt;span class="p">[&lt;/span>&lt;span class="s">5m&lt;/span>&lt;span class="p">]&lt;/span>&lt;span class="o">))&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>&lt;strong>Upload error rate as a percentage:&lt;/strong>&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-promql" data-lang="promql">&lt;span class="line">&lt;span class="cl">&lt;span class="k">sum&lt;/span>&lt;span class="o">(&lt;/span>&lt;span class="kr">rate&lt;/span>&lt;span class="o">(&lt;/span>&lt;span class="nv">sftpgo_uploads_errors_total&lt;/span>&lt;span class="p">[&lt;/span>&lt;span class="s">5m&lt;/span>&lt;span class="p">]&lt;/span>&lt;span class="o">))&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="o">/&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="k">sum&lt;/span>&lt;span class="o">(&lt;/span>&lt;span class="kr">rate&lt;/span>&lt;span class="o">(&lt;/span>&lt;span class="nv">sftpgo_uploads_total&lt;/span>&lt;span class="p">[&lt;/span>&lt;span class="s">5m&lt;/span>&lt;span class="p">]&lt;/span>&lt;span class="o">))&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="o">*&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="mi">100&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>&lt;strong>Data provider health (alert if any pod loses DB connectivity):&lt;/strong>&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-promql" data-lang="promql">&lt;span class="line">&lt;span class="cl">&lt;span class="k">min&lt;/span>&lt;span class="o">(&lt;/span>&lt;span class="nv">sftpgo_data_provider_availability&lt;/span>&lt;span class="o">)&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="o">==&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="mi">0&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>That last query is a good candidate for a Prometheus alert rule. If any replica can&amp;rsquo;t reach PostgreSQL, fire an alert. The &lt;code>min&lt;/code> catches the case where one pod is healthy but another has lost its connection.&lt;/p>
&lt;h4 id="tying-it-back-to-autoscaling">Tying It Back to Autoscaling&lt;/h4>
&lt;p>Remember the HPA note from the replicas section? &lt;code>sftpgo_active_connections&lt;/code> is your custom metric for scaling. With the &lt;a href="https://github.com/kubernetes-sigs/prometheus-adapter"target="_blank" rel="noopener noreferrer">Prometheus Adapter&lt;/a>, you can scale SFTPGo pods based on actual connection count instead of CPU:&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-yaml" data-lang="yaml">&lt;span class="line">&lt;span class="cl">&lt;span class="nt">apiVersion&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">autoscaling/v2&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">&lt;/span>&lt;span class="nt">kind&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">HorizontalPodAutoscaler&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">&lt;/span>&lt;span class="nt">metadata&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">name&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">sftpgo&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">namespace&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">sftpgo&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">&lt;/span>&lt;span class="nt">spec&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">scaleTargetRef&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">apiVersion&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">apps/v1&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">kind&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">Deployment&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">name&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">sftpgo&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">minReplicas&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="m">2&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">maxReplicas&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="m">10&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">metrics&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>- &lt;span class="nt">type&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">Pods&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">pods&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">metric&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">name&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">sftpgo_active_connections&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">target&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">type&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">AverageValue&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">averageValue&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="s2">&amp;#34;50&amp;#34;&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>When the average connection count per pod crosses 50, Kubernetes spins up another replica. When it drops, it scales back down. Way better than guessing based on CPU, which barely moves during SFTP transfers.&lt;/p>
&lt;h3 id="central-logging">Central Logging&lt;/h3>
&lt;p>Metrics tell you what&amp;rsquo;s happening right now. Logs tell you what happened at 2am when that vendor&amp;rsquo;s automated upload script decided to authenticate 400 times in a row with the wrong password.&lt;/p>
&lt;p>SFTPGo writes structured JSON logs to stdout by default, which is exactly what you want on Kubernetes. No sidecar containers, no log file rotation, no volume mounts for log directories. Your cluster&amp;rsquo;s log collector (Fluentd, Fluent Bit, Promtail, Vector, whatever you&amp;rsquo;re running) picks them up automatically from the container runtime.&lt;/p>
&lt;p>A typical SFTPGo log line looks like this:&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-json" data-lang="json">&lt;span class="line">&lt;span class="cl">&lt;span class="p">{&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="nt">&amp;#34;level&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="s2">&amp;#34;info&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="nt">&amp;#34;time&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="s2">&amp;#34;2026-04-11T14:32:01.123Z&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="nt">&amp;#34;sender&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="s2">&amp;#34;sftpd&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="nt">&amp;#34;message&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="s2">&amp;#34;User logged in&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="nt">&amp;#34;username&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="s2">&amp;#34;vendor-uploads&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="nt">&amp;#34;remote_address&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="s2">&amp;#34;10.244.3.15:48212&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="nt">&amp;#34;protocol&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="s2">&amp;#34;SFTP&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="nt">&amp;#34;connection_id&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="s2">&amp;#34;abc123&amp;#34;&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="p">}&lt;/span>
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>Every log entry includes the username, remote address, protocol, and connection ID. File operations include the file path and transfer size. Auth failures include the attempted username and the reason. All structured, all parseable, all ready to ship to wherever your logs land.&lt;/p>
&lt;h4 id="configuring-log-format">Configuring Log Format&lt;/h4>
&lt;p>SFTPGo defaults to JSON on stdout, but you can control the verbosity. Add a &lt;code>logger&lt;/code> block to the ConfigMap if you want to tune it:&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-json" data-lang="json">&lt;span class="line">&lt;span class="cl">&lt;span class="p">{&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="nt">&amp;#34;logger&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="p">{&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="nt">&amp;#34;enabled&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="kc">true&lt;/span>&lt;span class="p">,&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="nt">&amp;#34;level&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="s2">&amp;#34;info&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="nt">&amp;#34;utc_time&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="kc">true&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="p">}&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="p">}&lt;/span>
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>Keep it at &lt;code>info&lt;/code> for production. &lt;code>debug&lt;/code> is useful when you&amp;rsquo;re troubleshooting auth issues or virtual folder mappings, but it&amp;rsquo;s chatty. It logs every single SSH handshake step, every directory listing, every stat call. On a busy server, that&amp;rsquo;s a lot of log volume.&lt;/p>
&lt;h4 id="what-to-query-for">What to Query For&lt;/h4>
&lt;p>Once your logs are in Loki, Elasticsearch, or whatever your stack uses, these are the queries worth saving:&lt;/p>
&lt;p>&lt;strong>Failed authentication attempts&lt;/strong> are the first thing you want to see. Brute force attempts, expired credentials, misconfigured clients. In Loki with LogQL:&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-fallback" data-lang="fallback">&lt;span class="line">&lt;span class="cl">{namespace=&amp;#34;sftpgo&amp;#34;} | json | level=&amp;#34;error&amp;#34; | message=~&amp;#34;.*authentication.*&amp;#34;
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>&lt;strong>File transfer activity by user&lt;/strong> gives you an audit trail. Who uploaded what, when, and how big:&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-gdscript3" data-lang="gdscript3">&lt;span class="line">&lt;span class="cl">&lt;span class="p">{&lt;/span>&lt;span class="n">namespace&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="s2">&amp;#34;sftpgo&amp;#34;&lt;/span>&lt;span class="p">}&lt;/span> &lt;span class="o">|&lt;/span> &lt;span class="n">json&lt;/span> &lt;span class="o">|&lt;/span> &lt;span class="n">message&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="s2">&amp;#34;Upload&amp;#34;&lt;/span> &lt;span class="o">|&lt;/span> &lt;span class="n">line_format&lt;/span> &lt;span class="s2">&amp;#34;{{.username}} {{.virtual_path}} {{.elapsed_ms}}ms&amp;#34;&lt;/span>
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>&lt;strong>Connection patterns&lt;/strong> help you spot anomalies. A user that normally connects once a day suddenly hammering the server every 30 seconds is worth investigating:&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-fallback" data-lang="fallback">&lt;span class="line">&lt;span class="cl">sum by (username) (count_over_time({namespace=&amp;#34;sftpgo&amp;#34;} | json | message=&amp;#34;User logged in&amp;#34; [1h]))
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;h4 id="audit-trail">Audit Trail&lt;/h4>
&lt;p>For compliance-heavy environments (and if you&amp;rsquo;re handling file transfers for enterprise partners, you&amp;rsquo;re probably in one), SFTPGo&amp;rsquo;s structured logs give you a complete audit trail out of the box. Every login, every file operation, every disconnect. Combined with the username and remote IP in each log entry, you can reconstruct exactly who did what and when.&lt;/p>
&lt;p>Pipe these into a long-retention store (separate from your operational logs) and you&amp;rsquo;ve got audit coverage without bolting on a separate audit system. Set a retention policy that matches your compliance requirements and forget about it.&lt;/p>
&lt;h3 id="things-to-watch-out-for">Things to Watch Out For&lt;/h3>
&lt;p>&lt;strong>Keep the admin UI internal.&lt;/strong> The admin UI has full control over user accounts. Don&amp;rsquo;t Ingress it. Don&amp;rsquo;t LoadBalancer it. &lt;code>kubectl port-forward&lt;/code> when you need it, close it when you don&amp;rsquo;t. If you absolutely need remote access, put it behind an auth proxy and a VPN. Not just one of those. Both.&lt;/p>
&lt;p>&lt;strong>PostgreSQL backups.&lt;/strong> You&amp;rsquo;re running a real database now, which means you need real backups. &lt;code>pg_dump&lt;/code> on a cron, or use your managed PostgreSQL provider&amp;rsquo;s automated backups. The SFTPGo user database isn&amp;rsquo;t big, but losing it means recreating every user and virtual folder mapping from scratch.&lt;/p>
&lt;p>&lt;strong>Azure Blob isn&amp;rsquo;t a filesystem.&lt;/strong> Listing large directories can be slow because blobs use a flat namespace with prefix-based &amp;ldquo;directory&amp;rdquo; simulation. If your users are running &lt;code>ls&lt;/code> on a container with 500,000 objects, they&amp;rsquo;re going to have a bad time.&lt;/p>
&lt;h3 id="the-math">The Math&lt;/h3>
&lt;table>
&lt;thead>
&lt;tr>
&lt;th>&lt;/th>
&lt;th>Azure Native SFTP&lt;/th>
&lt;th>SFTPGo on K8s&lt;/th>
&lt;/tr>
&lt;/thead>
&lt;tbody>
&lt;tr>
&lt;td>Monthly endpoint cost&lt;/td>
&lt;td>~$219&lt;/td>
&lt;td>$0 (runs on existing cluster)&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Requires ADLS Gen2&lt;/td>
&lt;td>Yes&lt;/td>
&lt;td>No&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Multi-user support&lt;/td>
&lt;td>Local users only&lt;/td>
&lt;td>Full user management&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Quotas/throttling&lt;/td>
&lt;td>No&lt;/td>
&lt;td>Yes&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Audit logging&lt;/td>
&lt;td>Azure Monitor&lt;/td>
&lt;td>Built-in + syslog&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Prometheus metrics&lt;/td>
&lt;td>No&lt;/td>
&lt;td>Native /metrics endpoint&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Admin UI exposure&lt;/td>
&lt;td>N/A&lt;/td>
&lt;td>Internal only (port-forward)&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Setup complexity&lt;/td>
&lt;td>Toggle in portal&lt;/td>
&lt;td>Deploy to K8s&lt;/td>
&lt;/tr>
&lt;/tbody>
&lt;/table>
&lt;p>The &amp;ldquo;setup complexity&amp;rdquo; column is doing some heavy lifting for Azure there. Toggling a switch is simpler. But $219/month simpler? For most workloads, no.&lt;/p>
&lt;h3 id="wrapping-up">Wrapping Up&lt;/h3>
&lt;p>SFTPGo fills a gap that shouldn&amp;rsquo;t exist. SFTP is a 20+ year old protocol. Mapping it to object storage shouldn&amp;rsquo;t cost $2,600/year. Running your own is a few YAML files and a container image. The SFTPGo project is actively maintained, well-documented, and handles edge cases I haven&amp;rsquo;t even thought about yet.&lt;/p>
&lt;p>If you&amp;rsquo;re already running Kubernetes, this is maybe 20 minutes of work. The hardest part is explaining to your finance team why you&amp;rsquo;re &lt;em>not&lt;/em> using the Azure-native option.&lt;/p></description></item><item><title>Kubernetes for Cloud Engineers: Automating TLS with cert-manager</title><link>https://houdeshell.dev/post/2026-04-04_k8s-cert-manager/</link><pubDate>Sat, 04 Apr 2026 00:00:00 +0000</pubDate><guid>https://houdeshell.dev/post/2026-04-04_k8s-cert-manager/</guid><description>&lt;p>&lt;em>This is part two of a series for new operations and cloud engineers getting into Kubernetes. In &lt;a href="https://houdeshell.dev/post/2026-04-04_k8s-tls-default-cert/k8s-tls-default-cert/">part one&lt;/a> we covered how to set default TLS certificates on your ingress controllers. That works, but it means you&amp;rsquo;re managing certs by hand. Let&amp;rsquo;s fix that.&lt;/em>&lt;/p>
&lt;hr>
&lt;h2 id="why-cert-manager">Why cert-manager?&lt;/h2>
&lt;p>In the last post, we created TLS secrets manually. That&amp;rsquo;s fine for getting started. It&amp;rsquo;s not fine for production. Certificates expire. People forget to renew them. And then you&amp;rsquo;re getting paged at 3am because your site is serving a browser warning.&lt;/p>
&lt;p>&lt;a href="https://cert-manager.io/"target="_blank" rel="noopener noreferrer">cert-manager&lt;/a> is a Kubernetes-native certificate management controller. It watches your cluster for resources that need certificates, talks to a Certificate Authority (usually Let&amp;rsquo;s Encrypt), handles the challenge/response dance, stores the resulting cert as a Kubernetes secret, and renews it before it expires. All automatically. You configure it once and move on with your life.&lt;/p>
&lt;p>It&amp;rsquo;s one of those tools that, once installed, you forget it&amp;rsquo;s there. That&amp;rsquo;s the highest compliment I can give infrastructure software.&lt;/p>
&lt;h2 id="installing-cert-manager">Installing cert-manager&lt;/h2>
&lt;p>Two options. Pick whichever matches your deployment style.&lt;/p>
&lt;h3 id="option-1-helm-recommended">Option 1: Helm (recommended)&lt;/h3>
&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-bash" data-lang="bash">&lt;span class="line">&lt;span class="cl">helm install cert-manager oci://quay.io/jetstack/charts/cert-manager &lt;span class="se">\
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="se">&lt;/span> --version v1.20.0 &lt;span class="se">\
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="se">&lt;/span> --namespace cert-manager &lt;span class="se">\
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="se">&lt;/span> --create-namespace &lt;span class="se">\
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="se">&lt;/span> --set crds.enabled&lt;span class="o">=&lt;/span>&lt;span class="nb">true&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="c1"># NAME: cert-manager&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="c1"># NAMESPACE: cert-manager&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="c1"># STATUS: deployed&lt;/span>
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;h3 id="option-2-static-manifests">Option 2: Static manifests&lt;/h3>
&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-bash" data-lang="bash">&lt;span class="line">&lt;span class="cl">kubectl apply -f https://github.com/cert-manager/cert-manager/releases/download/v1.20.0/cert-manager.yaml
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>Both methods install the same thing: the cert-manager controller, webhook, cainjector, and six CRDs. Those CRDs are the building blocks you&amp;rsquo;ll use for everything else.&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-bash" data-lang="bash">&lt;span class="line">&lt;span class="cl">&lt;span class="c1"># Verify everything is running&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">kubectl get pods -n cert-manager
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="c1"># NAME READY STATUS&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="c1"># cert-manager-5c4b5f7b9-xk2lq 1/1 Running&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="c1"># cert-manager-cainjector-7f694c-m8p 1/1 Running&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="c1"># cert-manager-webhook-7cd8c8-9tn2f 1/1 Running&lt;/span>
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>Three pods running. That&amp;rsquo;s what you want to see.&lt;/p>
&lt;h2 id="issuers-telling-cert-manager-where-to-get-certificates">Issuers: telling cert-manager where to get certificates&lt;/h2>
&lt;p>cert-manager doesn&amp;rsquo;t know anything about Let&amp;rsquo;s Encrypt out of the box. You need to tell it where to go and how to prove you own your domains. That&amp;rsquo;s what Issuers do.&lt;/p>
&lt;p>There are two flavors:&lt;/p>
&lt;ul>
&lt;li>&lt;strong>Issuer&lt;/strong> is namespace-scoped. It can only issue certs for resources in the same namespace.&lt;/li>
&lt;li>&lt;strong>ClusterIssuer&lt;/strong> is cluster-scoped. It works across all namespaces.&lt;/li>
&lt;/ul>
&lt;p>For most setups, you want a ClusterIssuer. One config, whole cluster. If you have teams that need isolated certificate management per namespace, use an Issuer. But start simple.&lt;/p>
&lt;h2 id="staging-first-always">Staging first. Always.&lt;/h2>
&lt;p>Let&amp;rsquo;s Encrypt has two environments: staging and production. They work identically, but staging issues certificates signed by a fake CA that browsers don&amp;rsquo;t trust. The certificates themselves are structurally real. They just won&amp;rsquo;t show a green lock.&lt;/p>
&lt;p>Why does this matter? Because Let&amp;rsquo;s Encrypt production has &lt;a href="https://letsencrypt.org/docs/rate-limits/"target="_blank" rel="noopener noreferrer">rate limits&lt;/a>. Tight ones. 50 certificates per registered domain per week. 5 duplicate certificates per week. If you mess up your config and keep retrying, you can lock yourself out for days.&lt;/p>
&lt;p>Staging has much more generous limits. So you should always get your pipeline working against staging first, then switch to production once you know everything is wired up correctly.&lt;/p>
&lt;h3 id="create-a-staging-clusterissuer">Create a staging ClusterIssuer&lt;/h3>
&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-yaml" data-lang="yaml">&lt;span class="line">&lt;span class="cl">&lt;span class="c"># staging-issuer.yaml&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">&lt;/span>&lt;span class="nt">apiVersion&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">cert-manager.io/v1&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">&lt;/span>&lt;span class="nt">kind&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">ClusterIssuer&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">&lt;/span>&lt;span class="nt">metadata&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">name&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">letsencrypt-staging&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">&lt;/span>&lt;span class="nt">spec&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">acme&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">server&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">https://acme-staging-v02.api.letsencrypt.org/directory&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">email&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">you@example.com&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">privateKeySecretRef&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">name&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">letsencrypt-staging-account-key&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">solvers&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>- &lt;span class="nt">http01&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">ingress&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">ingressClassName&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">nginx&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-bash" data-lang="bash">&lt;span class="line">&lt;span class="cl">kubectl apply -f staging-issuer.yaml
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="c1"># clusterissuer.cert-manager.io/letsencrypt-staging created&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="c1"># Check that it registered successfully&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">kubectl get clusterissuer letsencrypt-staging
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="c1"># NAME READY AGE&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="c1"># letsencrypt-staging True 30s&lt;/span>
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>&lt;code>READY: True&lt;/code> means cert-manager registered an account with Let&amp;rsquo;s Encrypt staging. If you see &lt;code>False&lt;/code>, run &lt;code>kubectl describe clusterissuer letsencrypt-staging&lt;/code> and read the events.&lt;/p>
&lt;h3 id="create-the-production-clusterissuer">Create the production ClusterIssuer&lt;/h3>
&lt;p>Same thing, different ACME server URL:&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-yaml" data-lang="yaml">&lt;span class="line">&lt;span class="cl">&lt;span class="c"># production-issuer.yaml&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">&lt;/span>&lt;span class="nt">apiVersion&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">cert-manager.io/v1&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">&lt;/span>&lt;span class="nt">kind&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">ClusterIssuer&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">&lt;/span>&lt;span class="nt">metadata&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">name&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">letsencrypt-prod&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">&lt;/span>&lt;span class="nt">spec&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">acme&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">server&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">https://acme-v02.api.letsencrypt.org/directory&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">email&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">you@example.com&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">privateKeySecretRef&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">name&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">letsencrypt-prod-account-key&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">solvers&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>- &lt;span class="nt">http01&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">ingress&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">ingressClassName&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">nginx&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>Use separate &lt;code>privateKeySecretRef&lt;/code> names for staging and production. The ACME accounts are completely independent. You can&amp;rsquo;t reuse keys between environments.&lt;/p>
&lt;h2 id="http-01-vs-dns-01-how-you-prove-domain-ownership">HTTP-01 vs DNS-01: how you prove domain ownership&lt;/h2>
&lt;p>When you request a certificate, Let&amp;rsquo;s Encrypt needs proof that you actually own the domain. There are two ways to do this, and which one you pick depends on your setup.&lt;/p>
&lt;h3 id="http-01">HTTP-01&lt;/h3>
&lt;p>Let&amp;rsquo;s Encrypt sends an HTTP request to &lt;code>http://yourdomain.com/.well-known/acme-challenge/&amp;lt;token&amp;gt;&lt;/code>. cert-manager spins up a temporary pod and Ingress (or HTTPRoute) to serve the response. Once validated, the temp resources get cleaned up.&lt;/p>
&lt;p>This is the simpler option. It works if:&lt;/p>
&lt;ul>
&lt;li>Port 80 is publicly reachable on your cluster&lt;/li>
&lt;li>You don&amp;rsquo;t need wildcard certificates&lt;/li>
&lt;/ul>
&lt;p>It does not work if:&lt;/p>
&lt;ul>
&lt;li>You&amp;rsquo;re behind a firewall with no public HTTP access&lt;/li>
&lt;li>You need &lt;code>*.example.com&lt;/code> certs&lt;/li>
&lt;/ul>
&lt;h3 id="dns-01">DNS-01&lt;/h3>
&lt;p>Instead of an HTTP request, Let&amp;rsquo;s Encrypt checks for a specific TXT record at &lt;code>_acme-challenge.yourdomain.com&lt;/code>. cert-manager talks to your DNS provider&amp;rsquo;s API to create and clean up the record.&lt;/p>
&lt;p>This is the only way to get wildcard certificates. It&amp;rsquo;s also the right choice for internal domains that aren&amp;rsquo;t publicly accessible. The tradeoff is more setup. You need to give cert-manager API credentials for your DNS provider, and DNS propagation delays can slow things down.&lt;/p>
&lt;p>&lt;strong>Built-in DNS providers:&lt;/strong> Cloudflare, Route53, Google Cloud DNS, Azure DNS, DigitalOcean, Akamai, ACMEDNS, and RFC-2136. There are webhook extensions for 30+ more.&lt;/p>
&lt;h2 id="wiring-it-up-with-ingress">Wiring it up with Ingress&lt;/h2>
&lt;p>If you&amp;rsquo;re using the traditional Ingress API, cert-manager makes this really clean. Add an annotation to your Ingress resource, include a &lt;code>tls&lt;/code> block, and cert-manager handles the rest.&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-yaml" data-lang="yaml">&lt;span class="line">&lt;span class="cl">&lt;span class="c"># my-app-ingress.yaml&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">&lt;/span>&lt;span class="nt">apiVersion&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">networking.k8s.io/v1&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">&lt;/span>&lt;span class="nt">kind&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">Ingress&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">&lt;/span>&lt;span class="nt">metadata&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">name&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">my-app&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">annotations&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">cert-manager.io/cluster-issuer&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">letsencrypt-staging&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">&lt;/span>&lt;span class="nt">spec&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">ingressClassName&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">nginx&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">rules&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>- &lt;span class="nt">host&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">app.example.com&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">http&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">paths&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>- &lt;span class="nt">pathType&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">Prefix&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">path&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">/&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">backend&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">service&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">name&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">my-app&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">port&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">number&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="m">80&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">tls&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>- &lt;span class="nt">hosts&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>- &lt;span class="l">app.example.com&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">secretName&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">app-example-com-tls&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>That&amp;rsquo;s it. cert-manager sees the annotation, reads the &lt;code>tls&lt;/code> block, creates a Certificate resource, kicks off the ACME challenge, and stores the signed certificate in the &lt;code>app-example-com-tls&lt;/code> secret. Your ingress controller picks up the secret and starts serving HTTPS.&lt;/p>
&lt;p>When the cert is 30 days from expiration, cert-manager renews it automatically.&lt;/p>
&lt;div class="k8s-callout">
&lt;p>&lt;strong>Staging first, remember.&lt;/strong> Point the annotation at &lt;code>letsencrypt-staging&lt;/code> until you see a valid (but untrusted) cert in your browser. Then swap it to &lt;code>letsencrypt-prod&lt;/code>. You&amp;rsquo;ll need to delete the old secret so cert-manager issues a fresh one from production.&lt;/p>
&lt;/div>
&lt;p>Use &lt;code>cert-manager.io/cluster-issuer&lt;/code> for ClusterIssuers and &lt;code>cert-manager.io/issuer&lt;/code> for namespace-scoped Issuers. Mix them up and nothing will happen. No error, no cert. Just silence. Ask me how I know.&lt;/p>
&lt;h2 id="wiring-it-up-with-gateway-api">Wiring it up with Gateway API&lt;/h2>
&lt;p>If you followed the first post in this series, you know Gateway API is where things are heading. cert-manager supports it, but it needs a little extra configuration.&lt;/p>
&lt;h3 id="enable-gateway-api-support">Enable Gateway API support&lt;/h3>
&lt;p>Gateway API support is not on by default. You need to opt in.&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-bash" data-lang="bash">&lt;span class="line">&lt;span class="cl">helm upgrade --install cert-manager oci://quay.io/jetstack/charts/cert-manager &lt;span class="se">\
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="se">&lt;/span> --namespace cert-manager &lt;span class="se">\
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="se">&lt;/span> --set crds.enabled&lt;span class="o">=&lt;/span>&lt;span class="nb">true&lt;/span> &lt;span class="se">\
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="se">&lt;/span> --set config.enableGatewayAPI&lt;span class="o">=&lt;/span>&lt;span class="nb">true&lt;/span>
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>Make sure the Gateway API CRDs are installed in your cluster too:&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-bash" data-lang="bash">&lt;span class="line">&lt;span class="cl">kubectl apply --server-side &lt;span class="se">\
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="se">&lt;/span> -f https://github.com/kubernetes-sigs/gateway-api/releases/download/v1.4.1/standard-install.yaml
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>If you installed the CRDs after cert-manager was already running, restart it so it picks them up:&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-bash" data-lang="bash">&lt;span class="line">&lt;span class="cl">kubectl rollout restart deployment cert-manager -n cert-manager
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;h3 id="annotate-your-gateway">Annotate your Gateway&lt;/h3>
&lt;p>The pattern is similar to Ingress. Add the annotation, reference a secret in the listener&amp;rsquo;s &lt;code>certificateRefs&lt;/code>, and cert-manager fills in the rest.&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-yaml" data-lang="yaml">&lt;span class="line">&lt;span class="cl">&lt;span class="c"># gateway.yaml&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">&lt;/span>&lt;span class="nt">apiVersion&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">gateway.networking.k8s.io/v1&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">&lt;/span>&lt;span class="nt">kind&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">Gateway&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">&lt;/span>&lt;span class="nt">metadata&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">name&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">my-gateway&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">annotations&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">cert-manager.io/cluster-issuer&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">letsencrypt-prod&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">&lt;/span>&lt;span class="nt">spec&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">gatewayClassName&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">nginx&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">listeners&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>- &lt;span class="nt">name&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">https&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">hostname&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">app.example.com&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">port&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="m">443&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">protocol&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">HTTPS&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">tls&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">mode&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">Terminate&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">certificateRefs&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>- &lt;span class="nt">name&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">app-example-com-tls&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>cert-manager sees the annotation and the listener config, creates a Certificate for &lt;code>app.example.com&lt;/code>, and stores the result in the &lt;code>app-example-com-tls&lt;/code> secret. The Gateway picks it up and starts terminating TLS.&lt;/p>
&lt;p>Three things must be true for cert-manager to act on a Gateway listener:&lt;/p>
&lt;ol>
&lt;li>The &lt;code>hostname&lt;/code> is not empty&lt;/li>
&lt;li>The TLS &lt;code>mode&lt;/code> is &lt;code>Terminate&lt;/code> (not &lt;code>Passthrough&lt;/code>)&lt;/li>
&lt;li>There&amp;rsquo;s at least one entry in &lt;code>certificateRefs&lt;/code>&lt;/li>
&lt;/ol>
&lt;p>Miss any of those and cert-manager quietly ignores the listener.&lt;/p>
&lt;h2 id="wildcard-certificates-with-dns-01">Wildcard certificates with DNS-01&lt;/h2>
&lt;p>This is where DNS-01 earns its keep. Let&amp;rsquo;s say you want a single cert that covers &lt;code>*.example.com&lt;/code> so every subdomain is automatically secured. HTTP-01 can&amp;rsquo;t do this. Only DNS-01 can.&lt;/p>
&lt;p>I&amp;rsquo;ll use Cloudflare as the DNS provider since it&amp;rsquo;s common (and because I have a love/hate relationship with them that we&amp;rsquo;ve already discussed on the &lt;a href="https://houdeshell.dev/software/">software page&lt;/a>).&lt;/p>
&lt;h3 id="step-1-create-a-cloudflare-api-token">Step 1: Create a Cloudflare API token&lt;/h3>
&lt;p>In the Cloudflare dashboard, create an API token with these permissions:&lt;/p>
&lt;ul>
&lt;li>&lt;strong>Zone / DNS / Edit&lt;/strong>&lt;/li>
&lt;li>&lt;strong>Zone / Zone / Read&lt;/strong>&lt;/li>
&lt;/ul>
&lt;p>Scope it to the zone you need. Don&amp;rsquo;t use a global API key if you can avoid it.&lt;/p>
&lt;h3 id="step-2-store-the-token-in-your-cluster">Step 2: Store the token in your cluster&lt;/h3>
&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-yaml" data-lang="yaml">&lt;span class="line">&lt;span class="cl">&lt;span class="c"># The secret MUST be in the cert-manager namespace for ClusterIssuers&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">&lt;/span>&lt;span class="nt">apiVersion&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">v1&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">&lt;/span>&lt;span class="nt">kind&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">Secret&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">&lt;/span>&lt;span class="nt">metadata&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">name&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">cloudflare-api-token&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">namespace&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">cert-manager&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">&lt;/span>&lt;span class="nt">type&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">Opaque&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">&lt;/span>&lt;span class="nt">stringData&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">api-token&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">your-cloudflare-api-token-here&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>That namespace bit is important. When you use a ClusterIssuer, cert-manager looks for referenced secrets in the namespace where cert-manager itself is installed. Not the namespace of your Certificate. Not the namespace of your app. The cert-manager namespace. This trips up everyone at least once.&lt;/p>
&lt;h3 id="step-3-create-a-dns-01-clusterissuer">Step 3: Create a DNS-01 ClusterIssuer&lt;/h3>
&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-yaml" data-lang="yaml">&lt;span class="line">&lt;span class="cl">&lt;span class="c"># dns-issuer.yaml&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">&lt;/span>&lt;span class="nt">apiVersion&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">cert-manager.io/v1&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">&lt;/span>&lt;span class="nt">kind&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">ClusterIssuer&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">&lt;/span>&lt;span class="nt">metadata&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">name&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">letsencrypt-prod-dns&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">&lt;/span>&lt;span class="nt">spec&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">acme&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">server&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">https://acme-v02.api.letsencrypt.org/directory&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">email&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">you@example.com&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">privateKeySecretRef&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">name&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">letsencrypt-prod-dns-account-key&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">solvers&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>- &lt;span class="nt">dns01&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">cloudflare&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">apiTokenSecretRef&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">name&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">cloudflare-api-token&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">key&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">api-token&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;h3 id="step-4-request-the-wildcard-cert">Step 4: Request the wildcard cert&lt;/h3>
&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-yaml" data-lang="yaml">&lt;span class="line">&lt;span class="cl">&lt;span class="c"># wildcard-cert.yaml&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">&lt;/span>&lt;span class="nt">apiVersion&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">cert-manager.io/v1&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">&lt;/span>&lt;span class="nt">kind&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">Certificate&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">&lt;/span>&lt;span class="nt">metadata&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">name&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">wildcard-example-com&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">namespace&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">default&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">&lt;/span>&lt;span class="nt">spec&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">secretName&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">wildcard-example-com-tls&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">issuerRef&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">name&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">letsencrypt-prod-dns&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">kind&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">ClusterIssuer&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">dnsNames&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>- &lt;span class="s2">&amp;#34;*.example.com&amp;#34;&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>- &lt;span class="l">example.com&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>Include both &lt;code>*.example.com&lt;/code> and &lt;code>example.com&lt;/code> in the &lt;code>dnsNames&lt;/code>. The wildcard only covers subdomains, not the bare domain itself.&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-bash" data-lang="bash">&lt;span class="line">&lt;span class="cl">kubectl apply -f wildcard-cert.yaml
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="c1"># certificate.cert-manager.io/wildcard-example-com created&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="c1"># Watch it work&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">kubectl get certificate wildcard-example-com -w
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="c1"># NAME READY SECRET AGE&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="c1"># wildcard-example-com False wildcard-example-com-tls 5s&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="c1"># wildcard-example-com True wildcard-example-com-tls 47s&lt;/span>
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>DNS-01 is slower than HTTP-01 because of DNS propagation. Give it a minute or two. If it takes more than five minutes, start troubleshooting (see below).&lt;/p>
&lt;h2 id="when-things-go-wrong">When things go wrong&lt;/h2>
&lt;p>They will. Here&amp;rsquo;s how to figure out what happened.&lt;/p>
&lt;p>cert-manager has a chain of resources that it creates when processing a certificate request. Follow the chain from top to bottom:&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-bash" data-lang="bash">&lt;span class="line">&lt;span class="cl">&lt;span class="c1"># 1. Is the Certificate ready?&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">kubectl get certificates -A
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="c1"># 2. What does the CertificateRequest say?&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">kubectl get certificaterequest -A
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">kubectl describe certificaterequest &amp;lt;name&amp;gt; -n &amp;lt;namespace&amp;gt;
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="c1"># 3. Is the Issuer healthy?&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">kubectl get clusterissuer
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">kubectl describe clusterissuer &amp;lt;name&amp;gt;
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="c1"># 4. For Let&amp;#39;s Encrypt: check the ACME Order and Challenge&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">kubectl get orders -A
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">kubectl get challenges -A
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">kubectl describe challenge &amp;lt;name&amp;gt; -n &amp;lt;namespace&amp;gt;
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="c1"># 5. Check the cert-manager logs&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">kubectl logs -n cert-manager deployment/cert-manager --tail&lt;span class="o">=&lt;/span>&lt;span class="m">100&lt;/span>
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>Most problems fall into a handful of categories:&lt;/p>
&lt;p>&lt;strong>Challenge not reachable (HTTP-01).&lt;/strong> Port 80 isn&amp;rsquo;t open, or the temporary solver Ingress isn&amp;rsquo;t being picked up by your controller. Check that &lt;code>ingressClassName&lt;/code> in your solver config matches your actual ingress controller.&lt;/p>
&lt;p>&lt;strong>DNS propagation timeout (DNS-01).&lt;/strong> The TXT record was created but Let&amp;rsquo;s Encrypt can&amp;rsquo;t see it yet. If your cluster&amp;rsquo;s DNS resolver is slow, you can point cert-manager at public resolvers:&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-bash" data-lang="bash">&lt;span class="line">&lt;span class="cl">helm upgrade cert-manager oci://quay.io/jetstack/charts/cert-manager &lt;span class="se">\
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="se">&lt;/span> --namespace cert-manager &lt;span class="se">\
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="se">&lt;/span> --set &lt;span class="s1">&amp;#39;extraArgs={--dns01-recursive-nameservers-only,--dns01-recursive-nameservers=1.1.1.1:53\,9.9.9.9:53}&amp;#39;&lt;/span>
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>&lt;strong>Secret in the wrong namespace.&lt;/strong> The number one DNS-01 headache. ClusterIssuers look for API token secrets in the cert-manager namespace. Not your app namespace. Not default.&lt;/p>
&lt;p>&lt;strong>Rate limited.&lt;/strong> You hit Let&amp;rsquo;s Encrypt production limits. Switch back to staging, wait for the rate limit window to pass (usually 7 days), fix whatever caused the excessive requests, and try again.&lt;/p>
&lt;p>&lt;strong>Issuer not ready.&lt;/strong> &lt;code>kubectl describe clusterissuer&lt;/code> will tell you why. Usually it&amp;rsquo;s a bad email address, an unreachable ACME server, or a malformed &lt;code>privateKeySecretRef&lt;/code>.&lt;/p>
&lt;p>&lt;strong>Nothing happens at all.&lt;/strong> Check the annotation name. &lt;code>cert-manager.io/cluster-issuer&lt;/code> is not the same as &lt;code>cert-manager.io/issuer&lt;/code>. Using the wrong one for your Issuer type produces zero errors and zero certificates. It&amp;rsquo;s the most frustrating failure mode because there&amp;rsquo;s nothing in the logs.&lt;/p>
&lt;h2 id="a-note-on-certificate-lifetimes">A note on certificate lifetimes&lt;/h2>
&lt;p>Let&amp;rsquo;s Encrypt has been shortening certificate lifetimes. They started at 90 days, moved to 64, and are now pushing toward 45-day certificates. This doesn&amp;rsquo;t matter much if you&amp;rsquo;re using cert-manager because it handles renewal automatically (default is 30 days before expiration). But it does mean that if cert-manager breaks and you don&amp;rsquo;t notice, you have less runway before things go red.&lt;/p>
&lt;p>Monitor your certificates. At minimum, set up an alert on cert-manager pod health. Ideally, also alert on certificates that haven&amp;rsquo;t renewed within their expected window.&lt;/p>
&lt;h2 id="wrapping-up">Wrapping up&lt;/h2>
&lt;p>Here&amp;rsquo;s the workflow once everything is configured:&lt;/p>
&lt;ol>
&lt;li>Deploy an Ingress or Gateway with the cert-manager annotation&lt;/li>
&lt;li>cert-manager creates a Certificate resource&lt;/li>
&lt;li>cert-manager talks to Let&amp;rsquo;s Encrypt, completes the challenge&lt;/li>
&lt;li>The signed certificate lands in a Kubernetes secret&lt;/li>
&lt;li>Your ingress controller or gateway picks it up&lt;/li>
&lt;li>cert-manager renews it before expiration&lt;/li>
&lt;/ol>
&lt;p>No cron jobs. No manual &lt;code>certbot renew&lt;/code>. No calendar reminders. Just certificates that work.&lt;/p>
&lt;p>If you&amp;rsquo;re running through this series from the beginning, you now have default TLS certs on your ingress controllers and automated certificate management with Let&amp;rsquo;s Encrypt. That&amp;rsquo;s a solid foundation.&lt;/p>
&lt;p>Next up in this series: &lt;strong>DNS automation with external-dns.&lt;/strong> Because if cert-manager removes the manual work from certificates, external-dns does the same thing for DNS records.&lt;/p></description></item><item><title>Kubernetes for Cloud Engineers: Default TLS Certificates</title><link>https://houdeshell.dev/post/2026-04-04_k8s-tls-default-cert/</link><pubDate>Sat, 04 Apr 2026 00:00:00 +0000</pubDate><guid>https://houdeshell.dev/post/2026-04-04_k8s-tls-default-cert/</guid><description>&lt;p>&lt;em>This is the first post in a series for new operations and cloud engineers getting started with Kubernetes. Whether you&amp;rsquo;re running K3s on a Raspberry Pi, EKS in AWS, AKS in Azure, or anything in between, the concepts are the same. Let&amp;rsquo;s get into it.&lt;/em>&lt;/p>
&lt;hr>
&lt;h2 id="the-problem">The problem&lt;/h2>
&lt;p>You&amp;rsquo;ve got a Kubernetes cluster. You&amp;rsquo;ve got an ingress controller routing traffic. You hit your app over HTTPS and&amp;hellip; you get a browser warning about an untrusted self-signed certificate. Or worse, you&amp;rsquo;ve got ten services and you&amp;rsquo;re copy-pasting the same TLS secret into every single Ingress resource.&lt;/p>
&lt;p>The fix: &lt;strong>set a default TLS certificate&lt;/strong> on your ingress controller. One cert to rule them all. Any request that doesn&amp;rsquo;t match a more specific TLS config falls back to this default. Clean, simple, done.&lt;/p>
&lt;p>Every controller does this slightly differently. Let&amp;rsquo;s walk through each one.&lt;/p>
&lt;hr>
&lt;h2 id="first-create-your-tls-secret">First, create your TLS secret&lt;/h2>
&lt;p>This part is the same regardless of which controller you&amp;rsquo;re running. You need a Kubernetes TLS secret containing your certificate and private key.&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-bash" data-lang="bash">&lt;span class="line">&lt;span class="cl">&lt;span class="c1"># Create the TLS secret from your cert and key files&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">kubectl create secret tls default-tls &lt;span class="se">\
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="se">&lt;/span> --cert&lt;span class="o">=&lt;/span>tls.crt &lt;span class="se">\
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="se">&lt;/span> --key&lt;span class="o">=&lt;/span>tls.key &lt;span class="se">\
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="se">&lt;/span> -n default
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="c1"># secret/default-tls created&lt;/span>
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>Your cert file should contain the full chain: leaf → intermediate → root. If you&amp;rsquo;re using cert-manager or Let&amp;rsquo;s Encrypt, this is handled for you. If you&amp;rsquo;re doing it by hand, get the order right or you&amp;rsquo;ll be debugging trust chain errors at 2am.&lt;/p>
&lt;hr>
&lt;h2 id="ingress-controllers-legacy-ingress-api">Ingress Controllers (Legacy Ingress API)&lt;/h2>
&lt;div class="k8s-callout warning">
&lt;p>&lt;strong>Heads up: Ingress NGINX is retired.&lt;/strong> The &lt;code>kubernetes/ingress-nginx&lt;/code> project, the one most of us grew up on, officially reached end-of-life on &lt;strong>March 24, 2026&lt;/strong>. The repository is now read-only. No more releases, no bug fixes, &lt;strong>no security patches&lt;/strong>. Your existing deployments won&amp;rsquo;t break overnight, but you&amp;rsquo;re flying without a safety net.&lt;/p>
&lt;p>The Kubernetes Steering Committee and Security Response Committee issued a &lt;a href="https://kubernetes.io/blog/2026/01/29/ingress-nginx-statement/"target="_blank" rel="noopener noreferrer">joint statement&lt;/a> in January 2026 urging migration. The recommended path forward is &lt;strong>Gateway API&lt;/strong>, which has been GA since October 2023 and now has 20+ conformant implementations.&lt;/p>
&lt;p>If you&amp;rsquo;re starting fresh, skip to the &lt;a href="#gateway-api">Gateway API section&lt;/a> below. If you&amp;rsquo;re migrating, check out &lt;code>ingress2gateway&lt;/code>, the official migration tool that now supports 30+ annotation conversions.&lt;/p>
&lt;p>&lt;strong>Note:&lt;/strong> NGINX Inc.&amp;rsquo;s commercial ingress controller (F5/NGINX) is a completely separate codebase and remains actively maintained. Don&amp;rsquo;t confuse the two.&lt;/p>
&lt;/div>
&lt;h3 id="ingress-nginx-kubernetesingress-nginx">Ingress NGINX (kubernetes/ingress-nginx)&lt;/h3>
&lt;p>Even though it&amp;rsquo;s retired, you&amp;rsquo;ll encounter this in the wild for a while. The default cert is set via a controller argument.&lt;/p>
&lt;p>&lt;strong>With Helm:&lt;/strong>&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-yaml" data-lang="yaml">&lt;span class="line">&lt;span class="cl">&lt;span class="c"># values.yaml&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">&lt;/span>&lt;span class="nt">controller&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">extraArgs&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">default-ssl-certificate&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="s2">&amp;#34;default/default-tls&amp;#34;&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-bash" data-lang="bash">&lt;span class="line">&lt;span class="cl">helm upgrade ingress-nginx ingress-nginx/ingress-nginx &lt;span class="se">\
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="se">&lt;/span> -f values.yaml &lt;span class="se">\
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="se">&lt;/span> -n ingress-nginx
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>&lt;strong>Without Helm&lt;/strong>, patch the controller deployment directly:&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-yaml" data-lang="yaml">&lt;span class="line">&lt;span class="cl">&lt;span class="c"># Add to the container args in the ingress-nginx-controller Deployment&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">&lt;/span>&lt;span class="nt">spec&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">containers&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>- &lt;span class="nt">name&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">controller&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">args&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>- &lt;span class="l">/nginx-ingress-controller&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>- --&lt;span class="l">default-ssl-certificate=default/default-tls&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>The format is &lt;code>namespace/secret-name&lt;/code>. Without this flag, NGINX generates a self-signed certificate for the catch-all server. That&amp;rsquo;s the browser warning you&amp;rsquo;re seeing.&lt;/p>
&lt;p>&lt;strong>Gotcha:&lt;/strong> If your Ingress resource has a &lt;code>tls:&lt;/code> section but no &lt;code>secretName&lt;/code>, NGINX uses this default cert and forces an HTTPS redirect. If the &lt;code>tls:&lt;/code> section is missing entirely, it still serves the default cert but does &lt;em>not&lt;/em> redirect. Use &lt;code>force-ssl-redirect: &amp;quot;true&amp;quot;&lt;/code> in the ConfigMap if you want to enforce HTTPS everywhere.&lt;/p>
&lt;hr>
&lt;h3 id="haproxy-ingress">HAProxy Ingress&lt;/h3>
&lt;p>There are two HAProxy ingress projects: the community &lt;a href="https://haproxy-ingress.github.io/"target="_blank" rel="noopener noreferrer">haproxy-ingress&lt;/a> and the one from &lt;a href="https://github.com/haproxytech/kubernetes-ingress"target="_blank" rel="noopener noreferrer">HAProxy Technologies&lt;/a>. Both support the same flag pattern.&lt;/p>
&lt;p>&lt;strong>With Helm (HAProxy Technologies):&lt;/strong>&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-yaml" data-lang="yaml">&lt;span class="line">&lt;span class="cl">&lt;span class="c"># values.yaml&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">&lt;/span>&lt;span class="nt">controller&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">defaultTLSSecret&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">secretNamespace&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">default&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">secret&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">default-tls&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>&lt;strong>With controller args (both projects):&lt;/strong>&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-text" data-lang="text">&lt;span class="line">&lt;span class="cl">--default-ssl-certificate=default/default-tls
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-bash" data-lang="bash">&lt;span class="line">&lt;span class="cl">helm upgrade haproxy-ingress haproxytech/kubernetes-ingress &lt;span class="se">\
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="se">&lt;/span> --set controller.defaultTLSSecret.secretNamespace&lt;span class="o">=&lt;/span>default &lt;span class="se">\
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="se">&lt;/span> --set controller.defaultTLSSecret.secret&lt;span class="o">=&lt;/span>default-tls &lt;span class="se">\
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="se">&lt;/span> -n haproxy-ingress
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>Without a default cert configured, both HAProxy implementations generate a self-signed fake certificate. Same story as NGINX.&lt;/p>
&lt;p>&lt;strong>Note:&lt;/strong> HAProxy Technologies controller v1.11+ automatically enables QUIC (HTTP/3) when you set a default TLS cert. If you don&amp;rsquo;t want that yet, add &lt;code>--disable-quic&lt;/code>.&lt;/p>
&lt;hr>
&lt;h3 id="istio-ingress-gateway">Istio Ingress Gateway&lt;/h3>
&lt;p>Istio doesn&amp;rsquo;t use the Kubernetes Ingress API at all. It has its own &lt;code>Gateway&lt;/code> CRD (not the same as Gateway API, I know, the naming is painful).&lt;/p>
&lt;p>&lt;strong>Important:&lt;/strong> The TLS secret &lt;strong>must live in the same namespace as the Istio ingress gateway pod&lt;/strong>, typically &lt;code>istio-system&lt;/code>.&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-bash" data-lang="bash">&lt;span class="line">&lt;span class="cl">kubectl create secret tls default-tls &lt;span class="se">\
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="se">&lt;/span> --cert&lt;span class="o">=&lt;/span>tls.crt &lt;span class="se">\
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="se">&lt;/span> --key&lt;span class="o">=&lt;/span>tls.key &lt;span class="se">\
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="se">&lt;/span> -n istio-system
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="c1"># secret/default-tls created&lt;/span>
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>Then create the Gateway resource:&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-yaml" data-lang="yaml">&lt;span class="line">&lt;span class="cl">&lt;span class="c"># istio-gateway.yaml&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">&lt;/span>&lt;span class="nt">apiVersion&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">networking.istio.io/v1&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">&lt;/span>&lt;span class="nt">kind&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">Gateway&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">&lt;/span>&lt;span class="nt">metadata&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">name&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">default-gateway&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">&lt;/span>&lt;span class="nt">spec&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">selector&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">istio&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">ingressgateway&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">servers&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>- &lt;span class="nt">port&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">number&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="m">443&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">name&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">https&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">protocol&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">HTTPS&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">tls&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">mode&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">SIMPLE&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">credentialName&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">default-tls&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">hosts&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>- &lt;span class="s2">&amp;#34;*.example.com&amp;#34;&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-bash" data-lang="bash">&lt;span class="line">&lt;span class="cl">kubectl apply -f istio-gateway.yaml
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="c1"># gateway.networking.istio.io/default-gateway created&lt;/span>
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>&lt;strong>Gotchas:&lt;/strong>&lt;/p>
&lt;ul>
&lt;li>&lt;code>credentialName&lt;/code> must exactly match the Kubernetes secret name. No &lt;code>namespace/name&lt;/code> format here. It just looks in its own namespace.&lt;/li>
&lt;li>Istio doesn&amp;rsquo;t have a single &amp;ldquo;default certificate&amp;rdquo; concept. You configure TLS per-server block on the Gateway. To make it act as a default, use a wildcard host like &lt;code>*.example.com&lt;/code>.&lt;/li>
&lt;li>Wrong namespace is the #1 debugging headache. If TLS isn&amp;rsquo;t working, check the secret namespace first.&lt;/li>
&lt;/ul>
&lt;hr>
&lt;h2 id="gateway-api">Gateway API&lt;/h2>
&lt;p>Gateway API is the future. It&amp;rsquo;s the Kubernetes-native standard from SIG-Network, GA since October 2023, and every major ingress controller is building an implementation. If you&amp;rsquo;re starting fresh, start here.&lt;/p>
&lt;p>The big difference: there&amp;rsquo;s no &lt;code>--default-ssl-certificate&lt;/code> flag. TLS is configured &lt;strong>declaratively&lt;/strong> on the Gateway resource itself, per-listener. Each HTTPS listener explicitly references a certificate via &lt;code>certificateRefs&lt;/code>.&lt;/p>
&lt;h3 id="nginx-gateway-fabric">NGINX Gateway Fabric&lt;/h3>
&lt;p>NGINX Gateway Fabric is NGINX&amp;rsquo;s Gateway API implementation, completely separate from the retired Ingress NGINX project.&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-yaml" data-lang="yaml">&lt;span class="line">&lt;span class="cl">&lt;span class="c"># gateway.yaml&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">&lt;/span>&lt;span class="nt">apiVersion&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">gateway.networking.k8s.io/v1&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">&lt;/span>&lt;span class="nt">kind&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">Gateway&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">&lt;/span>&lt;span class="nt">metadata&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">name&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">default-gateway&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">&lt;/span>&lt;span class="nt">spec&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">gatewayClassName&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">nginx&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">listeners&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>- &lt;span class="nt">name&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">http&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">port&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="m">80&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">protocol&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">HTTP&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>- &lt;span class="nt">name&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">https&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">hostname&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="s2">&amp;#34;*.example.com&amp;#34;&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">port&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="m">443&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">protocol&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">HTTPS&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">tls&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">mode&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">Terminate&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">certificateRefs&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>- &lt;span class="nt">kind&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">Secret&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">name&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">default-tls&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-bash" data-lang="bash">&lt;span class="line">&lt;span class="cl">kubectl apply -f gateway.yaml
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="c1"># gateway.gateway.networking.k8s.io/default-gateway created&lt;/span>
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>Then attach your HTTPRoutes to the HTTPS listener:&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-yaml" data-lang="yaml">&lt;span class="line">&lt;span class="cl">&lt;span class="c"># route.yaml&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">&lt;/span>&lt;span class="nt">apiVersion&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">gateway.networking.k8s.io/v1&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">&lt;/span>&lt;span class="nt">kind&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">HTTPRoute&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">&lt;/span>&lt;span class="nt">metadata&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">name&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">my-app&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">&lt;/span>&lt;span class="nt">spec&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">parentRefs&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>- &lt;span class="nt">name&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">default-gateway&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">sectionName&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">https&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">hostnames&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>- &lt;span class="s2">&amp;#34;app.example.com&amp;#34;&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">rules&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>- &lt;span class="nt">backendRefs&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>- &lt;span class="nt">name&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">my-app&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">port&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="m">80&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>&lt;strong>With cert-manager&lt;/strong>, add an annotation and cert-manager handles the rest:&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-yaml" data-lang="yaml">&lt;span class="line">&lt;span class="cl">&lt;span class="c"># gateway.yaml: cert-manager will create and manage the secret&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">&lt;/span>&lt;span class="nt">apiVersion&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">gateway.networking.k8s.io/v1&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">&lt;/span>&lt;span class="nt">kind&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">Gateway&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">&lt;/span>&lt;span class="nt">metadata&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">name&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">default-gateway&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">annotations&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">cert-manager.io/cluster-issuer&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">letsencrypt-prod&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">&lt;/span>&lt;span class="nt">spec&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">gatewayClassName&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">nginx&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">listeners&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>- &lt;span class="nt">name&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">https&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">hostname&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="s2">&amp;#34;app.example.com&amp;#34;&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">port&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="m">443&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">protocol&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">HTTPS&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">tls&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">mode&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">Terminate&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">certificateRefs&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>- &lt;span class="nt">kind&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">Secret&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">name&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">default-tls&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;hr>
&lt;h3 id="haproxy-gateway-api">HAProxy (Gateway API)&lt;/h3>
&lt;p>The beauty of Gateway API is that it&amp;rsquo;s a standard. The Gateway resource looks almost identical regardless of the underlying implementation. You just swap the &lt;code>gatewayClassName&lt;/code>.&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-yaml" data-lang="yaml">&lt;span class="line">&lt;span class="cl">&lt;span class="c"># gateway.yaml: same pattern, different class&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">&lt;/span>&lt;span class="nt">apiVersion&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">gateway.networking.k8s.io/v1&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">&lt;/span>&lt;span class="nt">kind&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">Gateway&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">&lt;/span>&lt;span class="nt">metadata&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">name&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">default-gateway&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">&lt;/span>&lt;span class="nt">spec&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">gatewayClassName&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">haproxy&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">listeners&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>- &lt;span class="nt">name&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">https&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">hostname&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="s2">&amp;#34;*.example.com&amp;#34;&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">port&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="m">443&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">protocol&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">HTTPS&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">tls&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">mode&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">Terminate&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">certificateRefs&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>- &lt;span class="nt">kind&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">Secret&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">name&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">default-tls&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-bash" data-lang="bash">&lt;span class="line">&lt;span class="cl">kubectl apply -f gateway.yaml
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="c1"># gateway.gateway.networking.k8s.io/default-gateway created&lt;/span>
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>That&amp;rsquo;s it. Same spec. Same structure. The &lt;code>gatewayClassName&lt;/code> tells Kubernetes which controller reconciles the resource. This is exactly why Gateway API is the future. You can swap implementations without rewriting your config.&lt;/p>
&lt;p>&lt;strong>Gotchas for both implementations:&lt;/strong>&lt;/p>
&lt;ul>
&lt;li>Gateway API doesn&amp;rsquo;t have a global &amp;ldquo;default certificate&amp;rdquo; flag. Each HTTPS listener must explicitly reference a cert via &lt;code>certificateRefs&lt;/code>. To cover everything, use a wildcard hostname.&lt;/li>
&lt;li>The TLS secret must be in the &lt;strong>same namespace&lt;/strong> as the Gateway by default. For cross-namespace references, create a &lt;code>ReferenceGrant&lt;/code>.&lt;/li>
&lt;li>TLS modes: &lt;code>Terminate&lt;/code> means the gateway decrypts traffic (most common). &lt;code>Passthrough&lt;/code> forwards encrypted traffic as-is. Use this with &lt;code>TLSRoute&lt;/code>, not &lt;code>HTTPRoute&lt;/code>.&lt;/li>
&lt;li>NGINX Gateway Fabric currently supports only a single &lt;code>certificateRef&lt;/code> per listener. If you need multiple certs, create multiple listeners.&lt;/li>
&lt;/ul>
&lt;hr>
&lt;h2 id="wrapping-up">Wrapping up&lt;/h2>
&lt;p>Here&amp;rsquo;s the cheat sheet:&lt;/p>
&lt;table>
&lt;thead>
&lt;tr>
&lt;th>Controller&lt;/th>
&lt;th>Method&lt;/th>
&lt;th>Format&lt;/th>
&lt;/tr>
&lt;/thead>
&lt;tbody>
&lt;tr>
&lt;td>Ingress NGINX &lt;em>(retired)&lt;/em>&lt;/td>
&lt;td>&lt;code>--default-ssl-certificate&lt;/code>&lt;/td>
&lt;td>&lt;code>namespace/secret&lt;/code>&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>HAProxy Ingress&lt;/td>
&lt;td>&lt;code>--default-ssl-certificate&lt;/code>&lt;/td>
&lt;td>&lt;code>namespace/secret&lt;/code>&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Istio&lt;/td>
&lt;td>&lt;code>credentialName&lt;/code> on Gateway server&lt;/td>
&lt;td>secret name (same namespace)&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Gateway API (any impl)&lt;/td>
&lt;td>&lt;code>certificateRefs&lt;/code> on listener&lt;/td>
&lt;td>secret reference&lt;/td>
&lt;/tr>
&lt;/tbody>
&lt;/table>
&lt;p>If you&amp;rsquo;re starting fresh, go straight to Gateway API. Pick an implementation (NGINX Gateway Fabric, HAProxy, Envoy Gateway, whatever fits your stack), define your Gateway with a TLS listener, and move on.&lt;/p>
&lt;p>If you&amp;rsquo;re migrating from Ingress NGINX, take a breath. Your cluster won&amp;rsquo;t explode tomorrow. But start planning the move. The &lt;code>ingress2gateway&lt;/code> tool can automate most of the conversion, and the Gateway API spec is stable and well-documented.&lt;/p>
&lt;p>Next up in this series: &lt;strong>cert-manager and automated TLS&lt;/strong>. Creating secrets by hand is so 2024.&lt;/p></description></item><item><title>Engineering Managers Should Make Engineers Better</title><link>https://houdeshell.dev/post/2026-03-07_em-help/</link><pubDate>Sat, 07 Mar 2026 00:00:00 +0000</pubDate><guid>https://houdeshell.dev/post/2026-03-07_em-help/</guid><description>&lt;p>During my regularly scheduled lurking on Hacker News, I came across &lt;a href="https://newsletter.manager.dev/p/dont-become-an-engineering-manager"target="_blank" rel="noopener noreferrer">Don&amp;rsquo;t Become an Engineering Manager&lt;/a>, making the case that senior engineers should stay IC. The ladder is flattening, Staff pays better, tech is moving too fast to step away. Probably right, but it bugs me.&lt;/p>
&lt;p>It treats the EM role like a career trade. A thing you optimize for or against. And when you look at it that way, sure, the math is shaky right now. But that skips the question I actually care about: what does a good engineering manager &lt;em>do&lt;/em> for the engineers on their team?&lt;/p>
&lt;h3 id="the-actual-job">The actual job&lt;/h3>
&lt;p>The best EMs I&amp;rsquo;ve worked with spend most of their time on the unglamorous stuff. Not &amp;ldquo;shielding the team&amp;rdquo; in the vague r/LinkedInLunatics way. The specific stuff. Why has the deploy pipeline been flaky for two weeks and nobody&amp;rsquo;s fixed it? Two teams are about to build the same thing, so get them in a room. Product is asking for something unreasonable and the engineers are too polite to say so.&lt;/p>
&lt;p>None of that shows up on a career ladder. It barely shows up in performance reviews. But it&amp;rsquo;s the difference between a team that ships and a team that fights its own org.&lt;/p>
&lt;h3 id="making-people-better">Making people better&lt;/h3>
&lt;p>The part of the job I care about most doesn&amp;rsquo;t come up in any of these &amp;ldquo;should you become an EM&amp;rdquo; debates. Are the engineers on your team getting better? Not in the promotion-packet sense. Is the person who joined six months ago actually understanding the system now? Is the senior who&amp;rsquo;s great at heads-down execution starting to think about what happens &lt;em>after&lt;/em> they ship?&lt;/p>
&lt;p>That work is mostly invisible. It&amp;rsquo;s a one-on-one that looks like a casual conversation. It&amp;rsquo;s asking &amp;ldquo;what would you do?&amp;rdquo; instead of giving the answer. It&amp;rsquo;s putting someone slightly past their comfort zone and making sure they don&amp;rsquo;t eat it.&lt;/p>
&lt;h3 id="so-should-you-become-an-em">So should you become an EM?&lt;/h3>
&lt;p>If you care about people, and you want to make those people better engineers, then yes. Engineers need you.&lt;/p></description></item></channel></rss>