<?xml version="1.0" encoding="utf-8" standalone="yes"?><rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:content="http://purl.org/rss/1.0/modules/content/"><channel><title>Omegion</title><link>https://omegion.dev/</link><description>Recent content on Omegion</description><generator>Hugo</generator><language>en-us</language><copyright>Omegion</copyright><atom:link href="https://omegion.dev/index.xml" rel="self" type="application/rss+xml"/><item><title>Loggo: A Self-Hosted Note App for Engineers</title><link>https://omegion.dev/2026/10/loggo-a-self-hosted-note-app-for-engineers/</link><pubDate>Tue, 06 Oct 2026 00:00:00 +0000</pubDate><guid>https://omegion.dev/2026/10/loggo-a-self-hosted-note-app-for-engineers/</guid><description>Why I built my own note app after pen and paper and a pile of other apps stopped working for my daily notes.</description><content:encoded><![CDATA[<h2 id="introduction">Introduction</h2>
<p>I needed to take notes every day, so I started the way most people do: pen
and paper. It worked for a while. Then two things got annoying. I couldn&rsquo;t
search old notes, and I couldn&rsquo;t put a code block in them. For an engineer,
that&rsquo;s a real problem. I tried a lot of note apps after that. None of them
fit me 100%. So I built <a href="https://omegion.dev/projects/loggo/">Loggo</a>
, a
self-hosted note app for engineers, and it&rsquo;s open source.</p>
<a class="link-row-ext project-card" href="/projects/loggo/">
  <img class="project-card-logo" src="/projects/loggo/logo_hu_e69e4147f0193116.png" width="128" height="128" alt="">
  <div class="link-row-ext-inner">
    <span class="project-card-title" style="view-transition-name: vt-title-ecaed81585dc9614aec0e4718233d6ef">Loggo</span>
    <div class="project-card-meta">Launched September 2026</div>
    <div class="link-row-desc">Self-hosted note app for engineers. A day is a board; you drop Logs onto it all day.</div>
  </div>
  <span class="ext-arrow">&#8599;</span>
</a>
<h2 id="a-day-is-a-board">A day is a board</h2>
<p>The idea is simple. Every day is a board. Through the day, you drop Logs
onto it. A Log can be a thought, a command, a task, a snippet. I don&rsquo;t have
to decide where something goes. It goes on today&rsquo;s board.</p>
<p>The board is an infinite canvas. You can drag Logs around and resize them,
or switch to a masonry layout when you want something tidier. You can also
move between days with keyboard shortcuts.</p>
<h2 id="writing-and-finding-things">Writing and finding things</h2>
<p>Each Log is written in a Markdown editor, so code blocks work the way I
wanted from day one. There are tags and tasks, with due dates shown in the
editor. You can attach files, and tables and YouTube links render inside the
Log. Search is full-text and uses SQLite FTS5, which fixes the thing I hated
most about paper.</p>
<p>Workspaces let you keep personal and professional notes apart. Daily
templates keep each day&rsquo;s entries consistent.</p>
<h2 id="notes-stay-as-plain-files">Notes stay as plain files</h2>
<p>This is the part I care about most. Loggo uses SQLite for search and
metadata, but every Log is also written as a Markdown file with YAML
frontmatter, in a folder you can open without the app. The path includes the
date, like <code>workspaces/&lt;slug&gt;/YYYY/MM/DD/&lt;ulid&gt;-&lt;slug&gt;.md</code>.</p>
<p>If the database breaks, the files are still there. The frontmatter carries
enough information that <code>loggo rebuild</code> can build the database again from
the folder. Loggo never runs git on that folder. Committing it is up to me.</p>
<h2 id="built-on-what-i-already-had">Built on what I already had</h2>
<p>Loggo is the third one after <a href="https://omegion.dev/projects/recipery/">Recipery</a>

and <a href="https://omegion.dev/projects/bookhoarder/">Bookhoarder</a>
. By the time I
started it, I already had a working stack from those two: Next.js,
TypeScript, a pluggable storage layer (local disk or S3-compatible), and a
way to ship it as a Docker container, on Cloudflare, or on Kubernetes. I
didn&rsquo;t want to throw that away and start from zero. So I built on top of it.</p>
<p>One thing is different this time. Recipery and Bookhoarder have no database.
Loggo has one, because full-text search needs it. SQLite needs a single
writer, so the Kubernetes setup runs one replica with a persistent volume.
The Cloudflare version is a read-only demo, using D1 and R2.</p>
<h2 id="run-it">Run it</h2>
<div class="code-block">
  <div class="code-block-header">
    <span class="code-block-lang">sh</span>
    <button type="button" class="code-copy" aria-label="Copy code">
      <span class="code-copy-icon"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><rect x="9" y="9" width="13" height="13" rx="2" ry="2"/><path d="M5 15H4a2 2 0 01-2-2V4a2 2 0 012-2h9a2 2 0 012 2v1"/></svg>
</span>
      <span class="code-copy-icon code-copy-icon-check"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><polyline points="20 6 9 17 4 12"/></svg>
</span>
      <span class="code-copy-label">Copy</span>
    </button>
  </div>
  <div class="highlight"><pre tabindex="0" class="chroma"><code class="language-sh" data-lang="sh"><span class="line"><span class="cl">docker run -d <span class="se">\
</span></span></span><span class="line"><span class="cl">  --name loggo <span class="se">\
</span></span></span><span class="line"><span class="cl">  -p 3000:3000 <span class="se">\
</span></span></span><span class="line"><span class="cl">  -v /path/to/your/data:/app/data <span class="se">\
</span></span></span><span class="line"><span class="cl">  ghcr.io/loggo-dev/loggo:latest</span></span></code></pre></div>
</div>
<p>Open <code>http://localhost:3000</code> and you have today&rsquo;s board. The
<a href="https://docs.loggo.dev/deployment/docker/" target="_blank" rel="noopener noreferrer">Docker guide</a>
 has the full
setup, and the <a href="https://docs.loggo.dev" target="_blank" rel="noopener noreferrer">docs</a>
 cover the rest. The code is on
<a href="https://github.com/loggo-dev/loggo" target="_blank" rel="noopener noreferrer">GitHub</a>
 under AGPL-3.0.</p>
<h2 id="conclusion">Conclusion</h2>
<p>Paper couldn&rsquo;t search or hold code, and the other apps never fit. Loggo
does both, and my notes are plain Markdown files I can open without it. If I
ever stop using the app, I lose nothing.</p>
]]></content:encoded></item><item><title>Loggo</title><link>https://omegion.dev/projects/loggo/</link><pubDate>Sun, 06 Sep 2026 00:00:00 +0000</pubDate><guid>https://omegion.dev/projects/loggo/</guid><description>Self-hosted note app for engineers. A day is a board; you drop Logs onto it all day.</description><content:encoded>&lt;p>Loggo is a self-hosted note app for engineers. Every day is a board, and you
drop Logs onto it as the day goes. Logs are written in a Markdown editor with
code blocks, tags, tasks with due dates, and file attachments. The board is
an infinite canvas, with a masonry layout as an option.&lt;/p>
&lt;p>Search is full-text through SQLite FTS5. Every Log is also mirrored to a
Markdown file with YAML frontmatter, so the notes stay readable without the
app and the database can be rebuilt from the files. It supports multiple
workspaces and daily templates. It runs as a Docker container, on Kubernetes,
or as a read-only demo on Cloudflare Workers. Licensed under AGPL-3.0.&lt;/p>
</content:encoded></item><item><title>Recipery</title><link>https://omegion.dev/projects/recipery/</link><pubDate>Sun, 30 Aug 2026 00:00:00 +0000</pubDate><guid>https://omegion.dev/projects/recipery/</guid><description>Self-hosted recipe library backed by object storage, no database.</description><content:encoded>&lt;p>Recipery is a self-hosted recipe library. Paste a link and it scrapes the
title, ingredients, steps, and photo straight from the page, no manual entry.
Object storage is the only persistence layer: local disk, S3, or Cloudflare
R2, nothing to provision, back up, or migrate.&lt;/p>
&lt;p>It supports multiple profiles with admin and reader roles, each optionally
password-protected, and lets you organize recipes into collections. It runs
as a Docker container, a Cloudflare Worker, or on Kubernetes. Licensed under
AGPL-3.0.&lt;/p>
</content:encoded></item><item><title>Recipery: A Self-Hosted Recipe Library Backed by Object Storage</title><link>https://omegion.dev/2026/08/recipery-a-self-hosted-recipe-library-backed-by-object-storage/</link><pubDate>Sun, 30 Aug 2026 00:00:00 +0000</pubDate><guid>https://omegion.dev/2026/08/recipery-a-self-hosted-recipe-library-backed-by-object-storage/</guid><description>Why I built a self-hosted recipe manager on object storage instead of fighting Mealie&amp;rsquo;s backup and restore every time I moved my stack.</description><content:encoded><![CDATA[<h2 id="introduction">Introduction</h2>
<p>I used <a href="https://mealie.io/" target="_blank" rel="noopener noreferrer">Mealie</a>
 for my recipes for a couple of years.
It&rsquo;s a fine app, but every time I moved my stack to a new host, backup and
restore turned into a gamble. Mealie keeps recipes in a database plus a
separate folder for images, and moving both across at once, without them
drifting out of sync, never went as smoothly as it should have. More than
once I lost recipes and had to rebuild them from scratch. I&rsquo;d had enough.
I built <a href="https://omegion.dev/projects/recipery/">Recipery</a>
, a self-hosted recipe
library, and open sourced it from day one.</p>
<a class="link-row-ext project-card" href="/projects/recipery/">
  <img class="project-card-logo" src="/projects/recipery/logo_hu_6e3f852a3c3809f0.png" width="128" height="128" alt="">
  <div class="link-row-ext-inner">
    <span class="project-card-title" style="view-transition-name: vt-title-6896d0f7459b8d3da7e91a4fd7e0de3a">Recipery</span>
    <div class="project-card-meta">Launched August 2026</div>
    <div class="link-row-desc">Self-hosted recipe library backed by object storage, no database.</div>
  </div>
  <span class="ext-arrow">&#8599;</span>
</a>
<h2 id="object-storage-instead-of-a-database">Object storage instead of a database</h2>
<p>The part I kept getting burned by with Mealie was the database. A recipe
app&rsquo;s data isn&rsquo;t that complex, it&rsquo;s mostly text and a photo, but a
database still needs a proper backup step, a restore step, and both need to
survive a version upgrade without breaking. I didn&rsquo;t want to solve that
problem again. Recipery doesn&rsquo;t have a database at all. Everything, recipes,
images, profiles, collections, is written straight to object storage: local
disk, S3, or Cloudflare R2. The bucket is the only thing that persists.</p>
<p>That changes what &ldquo;moving my stack&rdquo; even means. If a host dies or I want to
redeploy somewhere else, the container is disposable. I point a fresh one at
the same bucket, and the library is there, because it never lived on that
host to begin with. I can also keep as many recipes and photos as I want,
S3 doesn&rsquo;t care, and I&rsquo;m not managing disk space on a VPS for a stray photo
of a lasagna.</p>
<h2 id="getting-recipes-in">Getting recipes in</h2>
<p>The other thing I wanted to fix was typing recipes in by hand. Paste a link
from a food blog into Recipery, and it scrapes the title, ingredients,
steps, and photo straight off the page.</p>
<h2 id="profiles-and-collections">Profiles and collections</h2>
<p>Recipery supports multiple profiles, each set to admin or reader access,
with an optional password per profile, so a shared library doesn&rsquo;t mean
shared edit rights. Recipes get grouped into collections instead of sitting
in one flat list.</p>
<h2 id="shopping-list-and-discovery">Shopping list and discovery</h2>
<p>There&rsquo;s a shopping list that pulls ingredients straight from a recipe, and
if two recipes share an ingredient, it merges them into one line instead of
listing it twice. Recipery can also search sites like NYT Cooking and BBC
Good Food directly, either an open search when you don&rsquo;t know what to cook,
or a &ldquo;find similar&rdquo; search that starts from a recipe already in the
library.</p>
<h2 id="conclusion">Conclusion</h2>
<p>Recipery is still new, but it already solved the one thing that mattered
to me: my recipes live in storage I control now, not in a database I have
to babysit across every move. If losing a recipe collection to a bad
restore sounds familiar, the code is on
<a href="https://github.com/recipery-dev/recipery" target="_blank" rel="noopener noreferrer">GitHub</a>
.</p>
]]></content:encoded></item><item><title>Bookhoarder</title><link>https://omegion.dev/projects/bookhoarder/</link><pubDate>Thu, 27 Aug 2026 00:00:00 +0000</pubDate><guid>https://omegion.dev/projects/bookhoarder/</guid><description>Self-hosted EPUB library backed by object storage, no database.</description><content:encoded>&lt;p>Bookhoarder is a self-hosted library for an EPUB collection. Upload your
books, read them in the browser, and send them straight to an e-reader.
Object storage is the only persistence layer: local disk, S3, or Cloudflare
R2, no database to provision or migrate between versions.&lt;/p>
&lt;p>It supports multiple reader profiles, metadata and cover lookup through
Open Library, and an OPDS catalog feed so any OPDS-compatible e-reader app
can browse and download books directly. It runs as a Docker container, a
Cloudflare Worker, or on Kubernetes. Licensed under the Elastic License 2.0:
free to self-host and modify, not to resell as a hosted service.&lt;/p>
</content:encoded></item><item><title>AI and Infrastructure Engineering</title><link>https://omegion.dev/2026/08/ai-and-infrastructure-engineering/</link><pubDate>Sun, 23 Aug 2026 00:00:00 +0000</pubDate><guid>https://omegion.dev/2026/08/ai-and-infrastructure-engineering/</guid><description>How I actually use AI in day-to-day infrastructure work, and why I think it&amp;rsquo;s the next layer of abstraction rather than a replacement for the engineer - the same way Kubernetes was for Ansible.</description><content:encoded><![CDATA[<h2 id="introduction">Introduction</h2>
<p>There&rsquo;s a push right now for whole companies to adopt AI wholesale - dump
every bit of context into it, write an <code>AGENTS.md</code> or <code>INSTRUCTIONS.md</code> in
every repo so any project is discoverable and contributable by an agent, not
just a human. Slightly funny, if you think about it: I&rsquo;ve never once gotten
a human teammate to actually read the README, and now we&rsquo;re all writing
better docs than we ever did, just aimed at a robot instead. The question
that comes with it is the obvious one: does this make engineering redundant? Once all the context about a stack and its
infrastructure is written down somewhere an agent can read it, are we next?</p>
<p>I don&rsquo;t think that&rsquo;s quite the right question, because we&rsquo;ve already lived
through a version of it.</p>
<h2 id="weve-been-here-before">We&rsquo;ve Been Here Before</h2>
<p>Did Kubernetes kill Ansible? Kind of. I haven&rsquo;t written an Ansible playbook
in years - if you handed me one right now I&rsquo;d be squinting at the module
syntax like I&rsquo;d never seen it before. Configuration management didn&rsquo;t stop
mattering. Kubernetes just made server management easy enough that we stopped
building our own node images at all - we just use whatever the cloud
provider hands us, an AWS AMI built for us, no questions asked. And when did
I last SSH into a node to debug something? Mostly never. If a node&rsquo;s acting
up, I kill it and hope the replacement doesn&rsquo;t have the same problem. The
next layer up went the same way: run a container on ECS Fargate, in a
Lambda, or on Cloudflare Containers, and I genuinely don&rsquo;t know or care what
node it landed on - but that doesn&rsquo;t mean nobody&rsquo;s orchestrating it, it
means I still decided that workload should be a container in the first
place, what image it runs, what it&rsquo;s allowed to talk to, how it scales, what
happens when it fails. Kubernetes and serverless containers didn&rsquo;t remove
that layer of decisions, they moved the unit of work up from &ldquo;the machine&rdquo;
to &ldquo;the workload,&rdquo; and everything below that layer got quietly automated
away.</p>
<p>Nobody would say Kubernetes, or Fargate, or Cloudflare&rsquo;s container platform,
replaced infrastructure engineers. Each one replaced a specific layer of
manual work - hand-building images, hand-patching boxes, knowing which node
a workload landed on - and the engineers moved up to the layer above it
every time. I think AI is doing the same thing again, one layer higher.</p>
<h2 id="what-changed-day-to-day">What Changed Day to Day</h2>
<p>I use Claude daily to generate Helm charts and write Terraform modules. The
part it actually removed from my day isn&rsquo;t the thinking - it&rsquo;s the
lookup work. I don&rsquo;t read through the AWS provider&rsquo;s changelog to figure out
what changed between v5 and v6 anymore; I describe what I want, in whatever
shape I want the module or chart to end up, and Claude produces a version of
it. It takes iteration to get it into the shape I&rsquo;d actually ship, but once
it&rsquo;s there, it becomes the example for next time - especially with an
<code>AGENTS.md</code> in the repo pointing at it.</p>
<p>The same thing happened one level down a while ago: I don&rsquo;t hand-write raw
Kubernetes YAML any more than I hand-write Ansible modules - that&rsquo;s what
Helm charts are for. Increasingly, I don&rsquo;t hand-write the Helm chart either -
the <a href="https://omegion.dev/2026/08/managing-helm-charts-as-dependencies-not-f-values.yaml/">chart-as-a-dependency pattern I run now</a>

is exactly the kind of thing I described to Claude and let it draft first.
I direct what it should do, and Claude writes it.</p>
<h2 id="what-hasnt-changed">What Hasn&rsquo;t Changed</h2>
<p>I still need to know what a good Terraform module or a well-structured Helm
chart looks like. When something genuinely goes wrong and killing the node
isn&rsquo;t an option, I still need to be able to SSH into it - the layer
above doesn&rsquo;t remove the layer below, it just moves how often you have to
touch it. I&rsquo;m also still the one deciding the actual shape of things: what
the final version of a module looks like, what&rsquo;s maintainable a year from
now, how a chart should be deployed and versioned. AI does the
time-consuming part. I still give the direction.</p>
<h2 id="the-skill-you-trade-away">The Skill You Trade Away</h2>
<p>The honest tradeoff: I&rsquo;m faster at building and debugging things than I was
two years ago, and I&rsquo;m also visibly rustier at the fundamentals underneath
that speed. My HCL syntax recall isn&rsquo;t what it used to be. Four years ago I
hand-wrote a nested <code>for</code> loop - four levels deep, tagging subnets across
regions and availability zones in another AWS account - and it took me
about an hour to get the syntax right:</p>
<div class="code-block">
  <div class="code-block-header">
    <span class="code-block-lang">hcl</span>
    <button type="button" class="code-copy" aria-label="Copy code">
      <span class="code-copy-icon"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><rect x="9" y="9" width="13" height="13" rx="2" ry="2"/><path d="M5 15H4a2 2 0 01-2-2V4a2 2 0 012-2h9a2 2 0 012 2v1"/></svg>
</span>
      <span class="code-copy-icon code-copy-icon-check"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><polyline points="20 6 9 17 4 12"/></svg>
</span>
      <span class="code-copy-label">Copy</span>
    </button>
  </div>
  <div class="highlight"><pre tabindex="0" class="chroma"><code class="language-hcl" data-lang="hcl"><span class="line"><span class="cl"><span class="k">locals</span> {
</span></span><span class="line"><span class="cl"><span class="n">  subnet_tags</span> <span class="o">=</span> <span class="k">merge</span><span class="p">([</span>
</span></span><span class="line"><span class="cl">    <span class="k">for</span> <span class="k">account</span><span class="p">,</span> <span class="k">regions</span> <span class="k">in</span> <span class="k">var</span><span class="p">.</span><span class="k">accounts</span> <span class="err">:</span> <span class="k">merge</span><span class="p">([</span>
</span></span><span class="line"><span class="cl">      <span class="k">for</span> <span class="k">region</span><span class="p">,</span> <span class="k">azs</span> <span class="k">in</span> <span class="k">regions</span> <span class="err">:</span> <span class="k">merge</span><span class="p">([</span>
</span></span><span class="line"><span class="cl">        <span class="k">for</span> <span class="k">az</span><span class="p">,</span> <span class="k">subnets</span> <span class="k">in</span> <span class="k">azs</span> <span class="err">:</span> {
</span></span><span class="line"><span class="cl">          <span class="k">for</span> <span class="k">subnet_id</span><span class="p">,</span> <span class="k">tags</span> <span class="k">in</span> <span class="k">subnets</span> <span class="err">:</span>
</span></span><span class="line"><span class="cl"><span class="n">          &#34;${account}/${region}/${az}/${subnet_id}&#34;</span> <span class="o">=</span><span class="err">&gt;</span> <span class="k">tags</span>
</span></span><span class="line"><span class="cl">        }
</span></span><span class="line"><span class="cl">      <span class="p">]...)</span>
</span></span><span class="line"><span class="cl">    <span class="p">]...)</span>
</span></span><span class="line"><span class="cl">  <span class="p">]...)</span>
</span></span><span class="line"><span class="cl">}</span></span></code></pre></div>
</div>
<p>Four <code>merge([...]...)</code> calls stacked on top of each other just to flatten a
map of a map of a map of subnets. Claude writes the equivalent in seconds now,
and if you asked me to produce that from scratch today, I&rsquo;d genuinely have
to sit and think about it. My reflexes for
debugging a broken node over SSH are a little slower than when that was the
only way I knew how to do it. I can feel that happening in real time, the
same way plenty of engineers who came up after Kubernetes never really
learned to hand-roll a node OS image, and were fine, because they never
needed to.</p>
<h2 id="where-this-goes-next">Where This Goes Next</h2>
<p>The part I&rsquo;m less sure about is how long &ldquo;I still give the direction&rdquo; holds.
Right now I&rsquo;m the one who decides the long-term shape of a stack, because I
have the context and the agent doesn&rsquo;t - not really, not beyond what&rsquo;s
written down in a repo&rsquo;s <code>AGENTS.md</code>. But that&rsquo;s exactly the gap those
company-wide AI pushes are trying to close: give the agent the <em>whole</em>
context, not just one repo&rsquo;s. If that actually works, an agent with a
genuine long-term view of the entire infrastructure - not just this
Terraform module, but every decision made across every repo for years -
might end up planning better than I do, the same way I can&rsquo;t out-debug a
tool that&rsquo;s read every changelog for every provider I use.</p>
<p>I think AI is still busy eating the layer just below &ldquo;give direction&rdquo;.
I&rsquo;m not fully convinced that&rsquo;s the last layer it eats. Ask me again in five
years whether &ldquo;I still give the direction&rdquo; was the whole story, or just
where I happened to be standing when I wrote this.</p>
]]></content:encoded></item><item><title>Hugo Omegion</title><link>https://omegion.dev/projects/hugo-omegion/</link><pubDate>Sun, 23 Aug 2026 00:00:00 +0000</pubDate><guid>https://omegion.dev/projects/hugo-omegion/</guid><description>The Hugo theme this site runs on, open-sourced as a standalone, reusable theme.</description><content:encoded></content:encoded></item><item><title>Managing Helm Charts as Dependencies, Not `-f values.yaml`</title><link>https://omegion.dev/2026/08/managing-helm-charts-as-dependencies-not-f-values.yaml/</link><pubDate>Sat, 22 Aug 2026 00:00:00 +0000</pubDate><guid>https://omegion.dev/2026/08/managing-helm-charts-as-dependencies-not-f-values.yaml/</guid><description>Wrapping an upstream Helm chart as a dependency of your own chart instead of overriding its values by hand - a pinned lockfile, room for your own templates, and one release instead of a command you have to remember to re-run.</description><content:encoded><![CDATA[<h2 id="introduction">Introduction</h2>
<p>I wanted to add <a href="https://www.chatwoot.com/" target="_blank" rel="noopener noreferrer">Chatwoot</a>
 to my homelab, and I
run everything there through GitOps with ArgoCD - a change is a commit, not
a command I typed once from my laptop. Chatwoot ships an official Helm
chart, so the obvious move was <code>helm repo add</code>, then <code>helm install</code> with my
own <code>values.yaml</code> on top. That gets it running, but re-running <code>helm upgrade</code> by hand every time a value changes isn&rsquo;t really GitOps, and if
Chatwoot needed something the chart doesn&rsquo;t ship - a <code>NetworkPolicy</code>, say -
there&rsquo;d be nowhere to put it except a second, unrelated manifest. That&rsquo;s
what pushed me toward a different pattern: instead of overriding an upstream
chart&rsquo;s values from the outside, make it a <em>dependency</em> of your own chart.
Chatwoot is just the example - the pattern applies to any chart you&rsquo;d
otherwise be installing straight from someone else&rsquo;s repo.</p>
<h2 id="the-usual-way">The Usual Way</h2>
<div class="code-block">
  <div class="code-block-header">
    <span class="code-block-lang">shell</span>
    <button type="button" class="code-copy" aria-label="Copy code">
      <span class="code-copy-icon"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><rect x="9" y="9" width="13" height="13" rx="2" ry="2"/><path d="M5 15H4a2 2 0 01-2-2V4a2 2 0 012-2h9a2 2 0 012 2v1"/></svg>
</span>
      <span class="code-copy-icon code-copy-icon-check"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><polyline points="20 6 9 17 4 12"/></svg>
</span>
      <span class="code-copy-label">Copy</span>
    </button>
  </div>
  <div class="highlight"><pre tabindex="0" class="chroma"><code class="language-shell" data-lang="shell"><span class="line"><span class="cl">❯ helm repo add chatwoot https://chatwoot.github.io/charts
</span></span><span class="line"><span class="cl">❯ helm install chatwoot chatwoot/chatwoot -n chatwoot -f values.yaml</span></span></code></pre></div>
</div>
<p>This works, but the chart version isn&rsquo;t pinned anywhere reviewable - it&rsquo;s
whatever <code>--version</code> you typed, or whatever <code>helm repo update</code> happened to
resolve to, sitting only in shell history. And <code>values.yaml</code> only makes
sense next to that version; nothing ties the two together.</p>
<h2 id="depend-on-it-instead">Depend on It Instead</h2>
<p>Make a small chart of your own, and declare the upstream chart as a
dependency rather than installing it directly:</p>
<div class="code-block">
  <div class="code-block-header">
    <span class="code-block-lang">yaml</span>
    <button type="button" class="code-copy" aria-label="Copy code">
      <span class="code-copy-icon"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><rect x="9" y="9" width="13" height="13" rx="2" ry="2"/><path d="M5 15H4a2 2 0 01-2-2V4a2 2 0 012-2h9a2 2 0 012 2v1"/></svg>
</span>
      <span class="code-copy-icon code-copy-icon-check"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><polyline points="20 6 9 17 4 12"/></svg>
</span>
      <span class="code-copy-label">Copy</span>
    </button>
  </div>
  <div class="highlight"><pre tabindex="0" class="chroma"><code class="language-yaml" data-lang="yaml"><span class="line"><span class="cl"><span class="c"># Chart.yaml</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="nt">apiVersion</span><span class="p">:</span><span class="w"> </span><span class="l">v2</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="nt">name</span><span class="p">:</span><span class="w"> </span><span class="l">chatwoot</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="nt">type</span><span class="p">:</span><span class="w"> </span><span class="l">application</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="nt">version</span><span class="p">:</span><span class="w"> </span><span class="m">0.1.0</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="nt">dependencies</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">  </span>- <span class="nt">name</span><span class="p">:</span><span class="w"> </span><span class="l">chatwoot</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">version</span><span class="p">:</span><span class="w"> </span><span class="m">2.0.24</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">repository</span><span class="p">:</span><span class="w"> </span><span class="l">https://chatwoot.github.io/charts</span></span></span></code></pre></div>
</div>
<div class="code-block">
  <div class="code-block-header">
    <span class="code-block-lang">shell</span>
    <button type="button" class="code-copy" aria-label="Copy code">
      <span class="code-copy-icon"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><rect x="9" y="9" width="13" height="13" rx="2" ry="2"/><path d="M5 15H4a2 2 0 01-2-2V4a2 2 0 012-2h9a2 2 0 012 2v1"/></svg>
</span>
      <span class="code-copy-icon code-copy-icon-check"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><polyline points="20 6 9 17 4 12"/></svg>
</span>
      <span class="code-copy-label">Copy</span>
    </button>
  </div>
  <div class="highlight"><pre tabindex="0" class="chroma"><code class="language-shell" data-lang="shell"><span class="line"><span class="cl">❯ helm dependency build
</span></span><span class="line"><span class="cl">Saving <span class="m">1</span> charts</span></span></code></pre></div>
</div>
<p>That resolves the dependency, writes the exact version and a digest to
<code>Chart.lock</code>, and downloads the packaged chart into
<code>charts/chatwoot-2.0.24.tgz</code>. A version bump is now editing one number in
<code>Chart.yaml</code> and re-running <code>helm dependency build</code> - a real diff on
<code>Chart.lock</code>, the same way a <code>go.sum</code> or <code>package-lock.json</code> pins a library
version, instead of a fact that only lives in shell history. And because
the chart and its dependency versions are just files, they deploy the same
way everything else in a GitOps repo does - ArgoCD (or Flux, or <code>helm upgrade</code> in CI) reconciles the directory, rather than someone needing to
remember to re-run a command.</p>
<pre class="mermaid" id="mermaid-3"></pre>
<script>(window.__mermaidSources ||= {})["mermaid-3"] = "flowchart TD\n  Chart[\"chatwoot\\n(your chart)\"] --\u003e|dependency| Upstream[\"chatwoot/charts\\nv2.0.24\"]";</script>

<p>Your chart, not the upstream one, is what gets installed - the upstream
chart just hangs off it as a dependency.</p>
<h2 id="renovate-bumps-it-for-you">Renovate Bumps It For You</h2>
<p>The other benefit of turning a chart version into a plain field in
<code>Chart.yaml</code> is that it stops being something only a human remembers to
check. <a href="https://docs.renovatebot.com/" target="_blank" rel="noopener noreferrer">Renovate</a>
&rsquo;s <code>helmv3</code> manager reads
<code>dependencies[].version</code> the same way it reads a <code>go.mod</code> or
<code>package.json</code> entry, checks the chart repository&rsquo;s index for anything
newer, and opens a PR:</p>
<p><img src="/2026/08/managing-helm-charts-as-dependencies-not-f-values.yaml/images/chart-yaml-diff.png" alt="Chart.yaml diff in a pull request, showing a dependency version bumped from 0.9.4 to 0.9.5" title="Renovate&#39;s diff on Chart.yaml"></p>
<p>That&rsquo;s a genuine PR from this same repo, just a different chart
(<code>external-secrets</code>) - same pattern as the Chatwoot one, one line changed,
<code>repository:</code> untouched. Renovate leaves its usual summary alongside it:</p>
<p><img src="/2026/08/managing-helm-charts-as-dependencies-not-f-values.yaml/images/renovate-pr-comment.png" alt="Renovate bot’s PR comment summarizing the patch update from 0.9.4 to 0.9.5" title="Renovate&#39;s PR comment"></p>
<p>It also keeps <code>Chart.lock</code> in sync with whatever it bumps, so the digest
never goes stale next to the version. The one thing it won&rsquo;t do on its own
is refresh a vendored <code>charts/*.tgz</code> - for that you need
<code>postUpdateOptions: [&quot;helmUpdateSubChartArchives&quot;]</code> in Renovate&rsquo;s config, so
the archive ArgoCD (or whatever renders the chart) actually reads gets
updated in the same PR as the version bump. Either way, keeping a chart
current goes from &ldquo;remember to check upstream&rdquo; to &ldquo;review a PR&rdquo; - the same
place every other dependency bump in the repo already shows up.</p>
<h2 id="values-same-shape-as-before">Values, Same Shape as Before</h2>
<p>Whatever you&rsquo;d have passed with <code>-f values.yaml</code> against the upstream chart
directly still works exactly the same - it just sits under the dependency&rsquo;s
name instead of the top level:</p>
<div class="code-block">
  <div class="code-block-header">
    <span class="code-block-lang">yaml</span>
    <button type="button" class="code-copy" aria-label="Copy code">
      <span class="code-copy-icon"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><rect x="9" y="9" width="13" height="13" rx="2" ry="2"/><path d="M5 15H4a2 2 0 01-2-2V4a2 2 0 012-2h9a2 2 0 012 2v1"/></svg>
</span>
      <span class="code-copy-icon code-copy-icon-check"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><polyline points="20 6 9 17 4 12"/></svg>
</span>
      <span class="code-copy-label">Copy</span>
    </button>
  </div>
  <div class="highlight"><pre tabindex="0" class="chroma"><code class="language-yaml" data-lang="yaml"><span class="line"><span class="cl"><span class="c"># values.yaml</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="nt">chatwoot</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">  </span><span class="nt">ingress</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">enabled</span><span class="p">:</span><span class="w"> </span><span class="kc">true</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">hosts</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span>- <span class="nt">host</span><span class="p">:</span><span class="w"> </span><span class="l">chatwoot.example.com</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">  </span><span class="nt">postgresql</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">enabled</span><span class="p">:</span><span class="w"> </span><span class="kc">false</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">postgresqlHost</span><span class="p">:</span><span class="w"> </span><span class="l">postgres.postgres.svc.cluster.local</span></span></span></code></pre></div>
</div>
<p>Nothing about the values themselves changes - it&rsquo;s still Chatwoot&rsquo;s own
<code>values.schema.json</code> underneath - only the namespacing, so your chart could
in principle depend on more than one thing.</p>
<h2 id="room-for-your-own-resources">Room for Your Own Resources</h2>
<p>This is the part a bare <code>helm install</code> genuinely can&rsquo;t give you: your
chart&rsquo;s own <code>templates/</code> directory sits right next to the dependency and
contributes to the same release. The <code>NetworkPolicy</code> I wanted for Chatwoot
is just another template file, not a manifest applied on the side:</p>
<div class="code-block">
  <div class="code-block-header">
    <span class="code-block-lang">yaml</span>
    <button type="button" class="code-copy" aria-label="Copy code">
      <span class="code-copy-icon"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><rect x="9" y="9" width="13" height="13" rx="2" ry="2"/><path d="M5 15H4a2 2 0 01-2-2V4a2 2 0 012-2h9a2 2 0 012 2v1"/></svg>
</span>
      <span class="code-copy-icon code-copy-icon-check"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><polyline points="20 6 9 17 4 12"/></svg>
</span>
      <span class="code-copy-label">Copy</span>
    </button>
  </div>
  <div class="highlight"><pre tabindex="0" class="chroma"><code class="language-yaml" data-lang="yaml"><span class="line"><span class="cl"><span class="c"># templates/networkpolicy.yaml</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="nt">apiVersion</span><span class="p">:</span><span class="w"> </span><span class="l">networking.k8s.io/v1</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="nt">kind</span><span class="p">:</span><span class="w"> </span><span class="l">NetworkPolicy</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="nt">metadata</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">  </span><span class="nt">name</span><span class="p">:</span><span class="w"> </span><span class="l">chatwoot-egress</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="nt">spec</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">  </span><span class="nt">podSelector</span><span class="p">:</span><span class="w"> </span>{}<span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">  </span><span class="nt">policyTypes</span><span class="p">:</span><span class="w"> </span><span class="p">[</span><span class="l">Egress]</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">  </span><span class="nt">egress</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span>- <span class="nt">to</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">        </span>- <span class="nt">namespaceSelector</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">            </span><span class="nt">matchLabels</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">              </span><span class="nt">kubernetes.io/metadata.name</span><span class="p">:</span><span class="w"> </span><span class="l">postgres</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span><span class="nt">ports</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">        </span>- <span class="nt">protocol</span><span class="p">:</span><span class="w"> </span><span class="l">TCP</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">          </span><span class="nt">port</span><span class="p">:</span><span class="w"> </span><span class="m">5432</span></span></span></code></pre></div>
</div>
<p><code>helm template</code> shows it rendering as one release, not two things
pretending to be related:</p>
<div class="code-block">
  <div class="code-block-header">
    <span class="code-block-lang">shell</span>
    <button type="button" class="code-copy" aria-label="Copy code">
      <span class="code-copy-icon"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><rect x="9" y="9" width="13" height="13" rx="2" ry="2"/><path d="M5 15H4a2 2 0 01-2-2V4a2 2 0 012-2h9a2 2 0 012 2v1"/></svg>
</span>
      <span class="code-copy-icon code-copy-icon-check"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><polyline points="20 6 9 17 4 12"/></svg>
</span>
      <span class="code-copy-label">Copy</span>
    </button>
  </div>
  <div class="highlight"><pre tabindex="0" class="chroma"><code class="language-shell" data-lang="shell"><span class="line"><span class="cl">❯ helm template chatwoot . -f values.yaml <span class="p">|</span> grep -E <span class="s1">&#39;^kind:&#39;</span> <span class="p">|</span> sort <span class="p">|</span> uniq -c
</span></span><span class="line"><span class="cl">   <span class="m">1</span> ConfigMap
</span></span><span class="line"><span class="cl">   <span class="m">2</span> Deployment
</span></span><span class="line"><span class="cl">   <span class="m">1</span> NetworkPolicy
</span></span><span class="line"><span class="cl">   <span class="m">1</span> Secret
</span></span><span class="line"><span class="cl">   <span class="m">4</span> Service
</span></span><span class="line"><span class="cl">   <span class="m">1</span> StatefulSet</span></span></code></pre></div>
</div>
<h2 id="conclusion">Conclusion</h2>
<p>The upstream chart still takes the same input - same chart, same options,
same maintainers. What&rsquo;s different is where things live: a pinned
version in <code>Chart.lock</code>, values in the same shape you&rsquo;d have passed with
<code>-f</code>, and a place for whatever the upstream chart doesn&rsquo;t cover. For a
GitOps setup like mine, that&rsquo;s the whole point - <code>helm upgrade</code> isn&rsquo;t a
command I run anymore, it&rsquo;s just what ArgoCD does with the directory every
time I push a commit.</p>
]]></content:encoded></item><item><title>Bookhoarder: A Self-Hosted EPUB Library Backed by Object Storage</title><link>https://omegion.dev/2026/08/bookhoarder-a-self-hosted-epub-library-backed-by-object-storage/</link><pubDate>Sat, 01 Aug 2026 00:00:00 +0000</pubDate><guid>https://omegion.dev/2026/08/bookhoarder-a-self-hosted-epub-library-backed-by-object-storage/</guid><description>Why I built a self-hosted EPUB library on top of the S3 bucket I already had, instead of trusting a Kindle library I don&amp;rsquo;t actually own.</description><content:encoded><![CDATA[<h2 id="introduction">Introduction</h2>
<p>I like reading on a Kindle, but I don&rsquo;t actually own any of those books. They&rsquo;re
DRM&rsquo;d, tied to one app, and I have no real guarantee I&rsquo;ll be able to open them
again in twenty years if Amazon decides otherwise. For the books I actually
want to keep, I&rsquo;ve been building an EPUB collection for 9 years, and it&rsquo;s a
proper archive by now, sitting in an S3 bucket. What I never solved was the
boring part: browsing that archive and getting a book onto an e-reader without
SSHing in and copying a file by hand. So I built
<a href="https://omegion.dev/projects/bookhoarder/">Bookhoarder</a>
, a self-hosted EPUB
library.</p>
<p>I wanted this one open source from the start, not as an afterthought. Anyone
with a bucket full of EPUBs has the same problem I did, and there was no
reason to keep the fix to myself.</p>
<a class="link-row-ext project-card" href="/projects/bookhoarder/">
  <img class="project-card-logo" src="/projects/bookhoarder/logo_hu_3e9e82bd16d90be7.png" width="128" height="128" alt="">
  <div class="link-row-ext-inner">
    <span class="project-card-title" style="view-transition-name: vt-title-c169edff5d89f47a501cf4e0a462ee77">Bookhoarder</span>
    <div class="project-card-meta">Launched August 2026</div>
    <div class="link-row-desc">Self-hosted EPUB library backed by object storage, no database.</div>
  </div>
  <span class="ext-arrow">&#8599;</span>
</a>
<h2 id="object-storage-instead-of-a-database">Object storage instead of a database</h2>
<p>Every self-hosted EPUB app I tried expected me to import my books into its own
database first. I didn&rsquo;t want a second copy of nine years of files living
inside some app&rsquo;s storage format, with its own backup and migration story on
top of the one I already had for the bucket. Bookhoarder skips that step
entirely. The storage driver is pluggable, local disk, S3, or Cloudflare R2,
and the bucket itself is the only thing that persists. There&rsquo;s no database to
provision, back up, or migrate between versions, because there isn&rsquo;t one.
Point it at the bucket I&rsquo;ve been using for years, and the library is already
there.</p>
<h2 id="reading-it-and-getting-it-to-a-device">Reading it and getting it to a device</h2>
<p>The point of the whole thing is getting a book off the shelf and in front of
my eyes, so the rest of Bookhoarder is built around that. There&rsquo;s an
in-browser reader, built on epub.js, for when I just want to read something
without touching a device. Metadata and covers come from Open Library, looked
up automatically and editable by hand when it gets something wrong. And for
the actual e-reader, there are two ways in: send a book over SMTP the same
way Kindle&rsquo;s own send-to-device email works, or point any OPDS-compatible
reader app at Bookhoarder&rsquo;s <code>/opds</code> feed and browse the whole library from the
device itself, no email round-trip needed.</p>
<h2 id="conclusion">Conclusion</h2>
<p>I still read plenty of things in Kindle&rsquo;s own app, that isn&rsquo;t going away. But
the books I actually want to keep now live somewhere I control, in a plain
format any reader can open, not locked to whichever app happens to still exist
in twenty years. The bucket and the OPDS feed are just plumbing. The point was
always owning the file.</p>
]]></content:encoded></item><item><title>Acne Tracker</title><link>https://omegion.dev/projects/acne-tracker/</link><pubDate>Sat, 16 May 2026 00:00:00 +0000</pubDate><guid>https://omegion.dev/projects/acne-tracker/</guid><description>A gentle acne journal with a personalized routine, same-position photo timeline, and an ingredient/barcode conflict scanner.</description><content:encoded>&lt;p>A 90-second skin quiz builds a personalized AM/PM routine. Progress
photos use deterministic face-positioning alignment instead of any AI
face scan or fabricated severity score, and a barcode/ingredient scanner
flags products that conflict with your routine without diagnosing
anything.&lt;/p>
</content:encoded></item><item><title>Follicle</title><link>https://omegion.dev/projects/follicle-journal/</link><pubDate>Sat, 16 May 2026 00:00:00 +0000</pubDate><guid>https://omegion.dev/projects/follicle-journal/</guid><description>A hair transplant and hair loss recovery journal with treatment logging, 3-angle photos, and milestone tracking keyed to your surgery date.</description><content:encoded>&lt;p>Built for men tracking hair transplant recovery or hair-loss treatment.
Daily treatment logging, 3-angle photos with consistent framing, and nine
rule-based milestone cards keyed off your surgery date (scab clearing,
shock loss warning, the shed at month 3, first growth). Photos are always
free to view, no paywall.&lt;/p>
</content:encoded></item><item><title>Hair Routine</title><link>https://omegion.dev/projects/hair-routine/</link><pubDate>Sat, 16 May 2026 00:00:00 +0000</pubDate><guid>https://omegion.dev/projects/hair-routine/</guid><description>A calm, on-device hair-care scheduler for wash days, treatments, and progress photos — no sign-in, no servers.</description><content:encoded>&lt;p>Same idea as Skincare Routine, for hair — on-device, no sign-in, no
servers. You set up a weekly schedule of wash days, treatment days, oil
days, and rest days, track which products you actually use, and capture
consistent weekly progress photos.&lt;/p>
</content:encoded></item><item><title>Skincare Routine</title><link>https://omegion.dev/projects/skincare-routine/</link><pubDate>Wed, 13 May 2026 00:00:00 +0000</pubDate><guid>https://omegion.dev/projects/skincare-routine/</guid><description>A calm, on-device skincare scheduler with AM/PM routines, smart product layering, and a private weekly progress photo journal.</description><content:encoded>&lt;p>It&amp;rsquo;s fully on-device — no sign-in, no servers, no analysis of your skin.
You build AM and PM routines with automatic product layering (cleanser →
toner → treatment → serum → eye → moisturizer → SPF), log each day with
one tap, and capture weekly progress photos with a positioning guide so
shots stay consistent over time.&lt;/p>
</content:encoded></item><item><title>When VPC Peering Stops Scaling: Moving to Transit Gateway</title><link>https://omegion.dev/2026/05/when-vpc-peering-stops-scaling-moving-to-transit-gateway/</link><pubDate>Tue, 12 May 2026 00:00:00 +0000</pubDate><guid>https://omegion.dev/2026/05/when-vpc-peering-stops-scaling-moving-to-transit-gateway/</guid><description>Why VPC peering stops scaling once Kubernetes clusters, a shared datastore, and Lambda functions all need their own VPC, and what moving to a Transit Gateway actually costs instead.</description><content:encoded><![CDATA[<h2 id="introduction">Introduction</h2>
<p>When I started at my current job, we were running self-managed, bare-metal
Kubernetes clusters on kOps, partway through a migration to EKS. That
migration was also our chance to redesign the networking. Under kOps, a
handful of clusters shared a VPC, roughly one VPC for every three or four
clusters, so around 80 clusters came out to a manageable number of VPCs.
For EKS we wanted a tighter boundary: one VPC per cluster, so a cluster&rsquo;s
blast radius stopped at its own network. That gave us the isolation we
wanted, and a new problem we hadn&rsquo;t planned for. Eighty clusters now meant
close to eighty VPCs, and VPC peering doesn&rsquo;t stay manageable at that size.</p>
<h2 id="the-mesh-problem">The mesh problem</h2>
<p>VPC peering connections are point to point. There&rsquo;s no such thing as
transitive peering, so if VPC A peers with B and B peers with C, A still
can&rsquo;t reach C unless you peer A and C directly too. That means the number of
connections you need grows with the number of <em>pairs</em> of VPCs, not the
number of VPCs.</p>
<p>With 4 VPCs that&rsquo;s 6 peering connections. With 10 VPCs it&rsquo;s 45. Worse, that
growth lands on you one VPC at a time: every new VPC needs its own peering
connection to every VPC that&rsquo;s already there.</p>
<pre class="mermaid" id="mermaid-0"></pre>
<script>(window.__mermaidSources ||= {})["mermaid-0"] = "%%{init: {\n  \"theme\": \"base\",\n  \"themeVariables\": {\n    \"primaryColor\": \"#FFF7ED\",\n    \"primaryBorderColor\": \"#FF9900\",\n    \"primaryTextColor\": \"#232F3E\",\n    \"lineColor\": \"#FF9900\"\n  }\n}}%%\n\nblock-beta\n    columns 5\n\n    space V1([\"VPC 1\"]) V2([\"VPC 2\"]) V3([\"VPC 3\"]) space\n    V10([\"VPC 10\"]) space:3 V4([\"VPC 4\"])\n    V9([\"VPC 9\"]) space:3 V5([\"VPC 5\"])\n    space V8([\"VPC 8\"]) V7([\"VPC 7\"]) V6([\"VPC 6\"]) space\n\n    V1 --- V2\n    V1 --- V3\n    V1 --- V4\n    V1 --- V5\n    V1 --- V6\n    V1 --- V7\n    V1 --- V8\n    V1 --- V9\n    V1 --- V10\n\n    V2 --- V3\n    V2 --- V4\n    V2 --- V5\n    V2 --- V6\n    V2 --- V7\n    V2 --- V8\n    V2 --- V9\n    V2 --- V10\n\n    V3 --- V4\n    V3 --- V5\n    V3 --- V6\n    V3 --- V7\n    V3 --- V8\n    V3 --- V9\n    V3 --- V10\n\n    V4 --- V5\n    V4 --- V6\n    V4 --- V7\n    V4 --- V8\n    V4 --- V9\n    V4 --- V10\n\n    V5 --- V6\n    V5 --- V7\n    V5 --- V8\n    V5 --- V9\n    V5 --- V10\n\n    V6 --- V7\n    V6 --- V8\n    V6 --- V9\n    V6 --- V10\n\n    V7 --- V8\n    V7 --- V9\n    V7 --- V10\n\n    V8 --- V9\n    V8 --- V10\n\n    V9 --- V10\n\n    classDef vpc fill:#FFF7ED,stroke:#FF9900,stroke-width:2px,color:#232F3E;\n    class V1,V2,V3,V4,V5,V6,V7,V8,V9,V10 vpc;";</script>

<p>Add a fifth VPC to a set of four and that&rsquo;s four new peering connections,
each with its own route table entries on both sides and its own security
group rules, not one. Multiply that by eighty clusters and you stop
reasoning about the mesh as a diagram at all, you just grep route tables
when something can&rsquo;t connect.</p>
<h2 id="where-transit-gateway-fits">Where Transit Gateway fits</h2>
<p>A <a href="https://aws.amazon.com/transit-gateway/" target="_blank" rel="noopener noreferrer">Transit Gateway</a>
 is a regional
router that VPCs attach to instead of attaching to each other. Each VPC
needs exactly one attachment, and traffic between any two VPCs routes
through the gateway.</p>
<pre class="mermaid" id="mermaid-1"></pre>
<script>(window.__mermaidSources ||= {})["mermaid-1"] = "architecture-beta\n    service tgw(internet)[Transit Gateway]\n    service vpcA(cloud)[VPC A]\n    service vpcB(cloud)[VPC B]\n    service vpcC(cloud)[VPC C]\n    service vpcD(cloud)[VPC D]\n\n    vpcA:B -- T:tgw\n    vpcB:R -- L:tgw\n    tgw:R -- L:vpcC\n    tgw:B -- T:vpcD";</script>

<p>Adding a VPC now means one attachment and one route, not one new connection
per existing VPC. The route tables live on the gateway, in one place,
instead of scattered across every VPC that needs to reach every other one.</p>
<p>The attachment itself is a small piece of Terraform:</p>
<div class="code-block">
  <div class="code-block-header">
    <span class="code-block-lang">hcl</span>
    <button type="button" class="code-copy" aria-label="Copy code">
      <span class="code-copy-icon"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><rect x="9" y="9" width="13" height="13" rx="2" ry="2"/><path d="M5 15H4a2 2 0 01-2-2V4a2 2 0 012-2h9a2 2 0 012 2v1"/></svg>
</span>
      <span class="code-copy-icon code-copy-icon-check"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><polyline points="20 6 9 17 4 12"/></svg>
</span>
      <span class="code-copy-label">Copy</span>
    </button>
  </div>
  <div class="highlight"><pre tabindex="0" class="chroma"><code class="language-hcl" data-lang="hcl"><span class="line"><span class="cl"><span class="k">resource</span> <span class="s2">&#34;aws_ec2_transit_gateway&#34; &#34;main&#34;</span> {
</span></span><span class="line"><span class="cl"><span class="n">  description</span> <span class="o">=</span> <span class="s2">&#34;Cluster VPC connectivity&#34;</span>
</span></span><span class="line"><span class="cl">}
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="k">resource</span> <span class="s2">&#34;aws_ec2_transit_gateway_vpc_attachment&#34; &#34;cluster&#34;</span> {
</span></span><span class="line"><span class="cl"><span class="n">  transit_gateway_id</span> <span class="o">=</span> <span class="k">aws_ec2_transit_gateway</span><span class="p">.</span><span class="k">main</span><span class="p">.</span><span class="k">id</span>
</span></span><span class="line"><span class="cl"><span class="n">  vpc_id</span>             <span class="o">=</span> <span class="k">var</span><span class="p">.</span><span class="k">vpc_id</span>
</span></span><span class="line"><span class="cl"><span class="n">  subnet_ids</span>         <span class="o">=</span> <span class="k">var</span><span class="p">.</span><span class="k">private_subnet_ids</span>
</span></span><span class="line"><span class="cl">}</span></span></code></pre></div>
</div>
<p>Every cluster VPC gets one of these attachments, and a route pointing at the
transit gateway for the CIDR ranges of the other VPCs. That&rsquo;s the whole
change, per VPC, from here on.</p>
<h2 id="the-same-problem-for-datastores-and-lambdas">The same problem for datastores and Lambdas</h2>
<p>Cluster VPCs peering with each other isn&rsquo;t the only traffic pushing on
this. Datastores usually end up isolated into their own VPC too, an RDS
instance behind its own subnets and security groups, and every cluster that
reads from it needs a path in. A Lambda function calling a service running
inside a cluster needs the same kind of path, just from the other
direction.</p>
<p>Neither of those is a new cluster added to the mesh, but each one is a new
participant, and peering treats it the same way it treats a cluster VPC:
one more VPC, one more connection to every VPC it needs to reach. A shared
RDS VPC that every one of 80 cluster VPCs reads from picks up all 80 of
those connections itself. Its route table and its security groups end up
tracking every consumer that was ever added.</p>
<pre class="mermaid" id="mermaid-3"></pre>
<script>(window.__mermaidSources ||= {})["mermaid-3"] = "architecture-beta\n    service tgw(internet)[Transit Gateway]\n\n    group clusterA(cloud)[Cluster VPC]\n    group clusterB(cloud)[Cluster VPC]\n    group data(cloud)[Datastore VPC]\n    group fnGroup(cloud)[Lambda VPC]\n\n    service c1(server)[Workload] in clusterA\n    service c2(server)[Workload] in clusterB\n    service db(database)[RDS] in data\n    service fn(server)[Lambda] in fnGroup\n\n    fn:B -- T:tgw\n    c1:L -- R:tgw\n    c2:T -- B:tgw\n    db:R -- L:tgw";</script>

<p>Put that RDS VPC on the Transit Gateway instead and it&rsquo;s one more
attachment, same as a cluster. A Lambda VPC is the same story. Reachability
now comes down to
<a href="https://docs.aws.amazon.com/vpc/latest/tgw/tgw-route-tables.html" target="_blank" rel="noopener noreferrer">Transit Gateway route tables</a>
,
not which VPCs happen to have a peering connection to the datastore: an
attachment only gets a route to it if it&rsquo;s associated with a route table
that carries one.</p>
<div class="code-block">
  <div class="code-block-header">
    <span class="code-block-lang">hcl</span>
    <button type="button" class="code-copy" aria-label="Copy code">
      <span class="code-copy-icon"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><rect x="9" y="9" width="13" height="13" rx="2" ry="2"/><path d="M5 15H4a2 2 0 01-2-2V4a2 2 0 012-2h9a2 2 0 012 2v1"/></svg>
</span>
      <span class="code-copy-icon code-copy-icon-check"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><polyline points="20 6 9 17 4 12"/></svg>
</span>
      <span class="code-copy-label">Copy</span>
    </button>
  </div>
  <div class="highlight"><pre tabindex="0" class="chroma"><code class="language-hcl" data-lang="hcl"><span class="line"><span class="cl"><span class="k">resource</span> <span class="s2">&#34;aws_ec2_transit_gateway_route_table&#34; &#34;datastores&#34;</span> {
</span></span><span class="line"><span class="cl"><span class="n">  transit_gateway_id</span> <span class="o">=</span> <span class="k">aws_ec2_transit_gateway</span><span class="p">.</span><span class="k">main</span><span class="p">.</span><span class="k">id</span>
</span></span><span class="line"><span class="cl">}
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="k">resource</span> <span class="s2">&#34;aws_ec2_transit_gateway_route_table_association&#34; &#34;rds&#34;</span> {
</span></span><span class="line"><span class="cl"><span class="n">  transit_gateway_attachment_id</span>  <span class="o">=</span> <span class="k">aws_ec2_transit_gateway_vpc_attachment</span><span class="p">.</span><span class="k">rds</span><span class="p">.</span><span class="k">id</span>
</span></span><span class="line"><span class="cl"><span class="n">  transit_gateway_route_table_id</span> <span class="o">=</span> <span class="k">aws_ec2_transit_gateway_route_table</span><span class="p">.</span><span class="k">datastores</span><span class="p">.</span><span class="k">id</span>
</span></span><span class="line"><span class="cl">}
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="k">resource</span> <span class="s2">&#34;aws_ec2_transit_gateway_route_table_association&#34; &#34;cluster&#34;</span> {
</span></span><span class="line"><span class="cl"><span class="n">  transit_gateway_attachment_id</span>  <span class="o">=</span> <span class="k">aws_ec2_transit_gateway_vpc_attachment</span><span class="p">.</span><span class="k">cluster</span><span class="p">.</span><span class="k">id</span>
</span></span><span class="line"><span class="cl"><span class="n">  transit_gateway_route_table_id</span> <span class="o">=</span> <span class="k">aws_ec2_transit_gateway_route_table</span><span class="p">.</span><span class="k">datastores</span><span class="p">.</span><span class="k">id</span>
</span></span><span class="line"><span class="cl">}</span></span></code></pre></div>
</div>
<p>Only attachments associated with <code>datastores</code> get a route to the RDS VPC.
Everything else on the gateway can still reach the cluster VPCs directly,
just not the datastore behind them.</p>
<p>This is also where the
<a href="https://omegion.dev/2026/04/how-to-reach-a-lambda-in-another-aws-region-without-vpc-peering/">Lambda cross-region PrivateLink setup</a>

I wrote about earlier stops applying. That one existed because the Lambda
needed to cross a region boundary we deliberately don&rsquo;t route through. A
Lambda in the same region calling a cluster service doesn&rsquo;t need a one-off
endpoint at all once its VPC is already an attachment on the gateway
everything else uses. It just needs a route, the same as any other
consumer.</p>
<h2 id="what-transit-gateway-costs">What Transit Gateway costs</h2>
<p><a href="https://aws.amazon.com/vpc/pricing/" target="_blank" rel="noopener noreferrer">VPC peering</a>
 connections themselves
are free. Data crossing a peering connection is billed like any other
cross-AZ traffic, $0.01 per GB, same region, in each direction.
<a href="https://aws.amazon.com/transit-gateway/pricing/" target="_blank" rel="noopener noreferrer">Transit Gateway</a>
 bills
differently: $0.05 per attachment per hour, whether or not it&rsquo;s carrying
anything, plus $0.02 per GB processed, twice the peering rate (us-east-1
figures, other regions vary slightly).</p>
<p>Take the 80 cluster VPCs from the redesign, plus the RDS VPC and the Lambda
VPC from the last section, moving a combined 20 TB in a month:</p>
<div class="code-block">
  <div class="code-block-header">
    <span class="code-block-lang">text</span>
    <button type="button" class="code-copy" aria-label="Copy code">
      <span class="code-copy-icon"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><rect x="9" y="9" width="13" height="13" rx="2" ry="2"/><path d="M5 15H4a2 2 0 01-2-2V4a2 2 0 012-2h9a2 2 0 012 2v1"/></svg>
</span>
      <span class="code-copy-icon code-copy-icon-check"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><polyline points="20 6 9 17 4 12"/></svg>
</span>
      <span class="code-copy-label">Copy</span>
    </button>
  </div>
  <div class="highlight"><pre tabindex="0" class="chroma"><code class="language-text" data-lang="text"><span class="line"><span class="cl">VPC peering, full mesh
</span></span><span class="line"><span class="cl">  cluster to cluster   80 × 79 / 2           = 3,160 connections
</span></span><span class="line"><span class="cl">  RDS to each cluster  80 × 1                =    80 connections
</span></span><span class="line"><span class="cl">  Lambda to its cluster                      =     1 connection
</span></span><span class="line"><span class="cl">  connections total                          = 3,241
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl">  data transfer  20,000 GB × $0.01/GB        = $200/mo
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl">Transit Gateway
</span></span><span class="line"><span class="cl">  attachments    82 × $0.05 × 24h × 30d      = $2,952/mo
</span></span><span class="line"><span class="cl">  data transfer  20,000 GB × $0.02/GB        =   $400/mo
</span></span><span class="line"><span class="cl">  total                                      = $3,352/mo</span></span></code></pre></div>
</div>
<p>That extra $3,152 a month buys 82 attachments to one gateway instead of
3,241 pairwise connections nobody could keep straight, the RDS VPC and the
Lambda VPC included.</p>
<h2 id="conclusion">Conclusion</h2>
<p>Peering doesn&rsquo;t fail loudly. Nothing breaks when you add one more VPC past
the point where the mesh stopped scaling, you just accumulate more manual
route table work than any one person can track correctly. The other cost
never shows up on a bill: try explaining a 3,241-connection peering
topology to an engineer who joined last week, versus pointing at one
gateway and saying everything goes through here. If I were redesigning
that networking again knowing we&rsquo;d end up with eighty VPCs, I&rsquo;d go straight
to a Transit Gateway instead of peering.</p>
]]></content:encoded></item><item><title>How to Reach a Lambda in Another AWS Region Without VPC Peering</title><link>https://omegion.dev/2026/04/how-to-reach-a-lambda-in-another-aws-region-without-vpc-peering/</link><pubDate>Sun, 26 Apr 2026 00:00:00 +0000</pubDate><guid>https://omegion.dev/2026/04/how-to-reach-a-lambda-in-another-aws-region-without-vpc-peering/</guid><description>Giving one Lambda a one-time, one-way path into another AWS region with cross-region PrivateLink, instead of opening up region isolation for everyone.</description><content:encoded><![CDATA[<h2 id="introduction">Introduction</h2>
<p>Our infra isolates AWS regions on purpose. Nothing in <code>eu-west-1</code> talks
directly to anything in <code>us-east-1</code>, and nothing routes back the other way.
It keeps a bad deploy or a bad actor in one region from becoming a problem in
both. A team came to me needing exactly the thing that policy exists to
prevent: a management app running in <code>eu-west-1</code> had to call services that
only exist in <code>us-east-1</code>. It was a one-time need for one app, not a reason
to open the regions up to each other generally, so VPC peering or a Transit
Gateway were both off the table. I needed a path that was narrow enough to
still call it isolation.</p>
<h2 id="prerequisites">Prerequisites</h2>
<ol>
<li>Two AWS regions already set up with their own VPCs, private subnets, and
Lambda functions. I&rsquo;ll call them <code>euw1</code> and <code>use1</code> below.</li>
<li><a href="https://developer.hashicorp.com/terraform" target="_blank" rel="noopener noreferrer">Terraform</a>
 with the
<a href="https://registry.terraform.io/providers/hashicorp/aws/latest" target="_blank" rel="noopener noreferrer">AWS provider</a>

at 5.83 or newer. Cross-region PrivateLink support landed in that version.</li>
<li>Comfort with VPC endpoints, NLBs, and ALBs. I won&rsquo;t re-explain what those
are.</li>
</ol>
<h2 id="the-shape-of-the-fix">The shape of the fix</h2>
<p><a href="https://aws.amazon.com/privatelink/" target="_blank" rel="noopener noreferrer">AWS PrivateLink</a>
 lets a VPC endpoint in
one region connect to an endpoint service in another, over AWS&rsquo;s own
network, no peering or internet exposure involved. That&rsquo;s the narrow path:
one interface endpoint in <code>euw1</code>, pointed at one endpoint service in <code>use1</code>,
carrying traffic for exactly this app and nothing else.</p>
<p>An endpoint service has to sit behind a Network Load Balancer, and an NLB
can&rsquo;t target a Lambda function directly, only instances, IPs, or an ALB. So
the US side ends up as a small stack: NLB in front, forwarding to an ALB
target group, which is the piece that&rsquo;s actually allowed to invoke Lambda.</p>
<pre class="mermaid" id="mermaid-0"></pre>
<script>(window.__mermaidSources ||= {})["mermaid-0"] = "flowchart TD\n  L1[\"Lambda (euw1)\"] --\u003e EP[\"Interface endpoint (euw1)\"]\n  EP -.-\u003e|PrivateLink, cross-region| ES[\"Endpoint service (use1)\"]\n  ES --\u003e NLB[\"NLB (use1)\"]\n  NLB --\u003e ALB[\"ALB (use1)\"]\n  ALB --\u003e L2[\"Lambda (use1)\"]\n  L2 --\u003e SVC[\"US services\"]";</script>

<h2 id="setting-up-the-us-side">Setting up the US side</h2>
<p>Two providers, one per region, since everything below is split across them:</p>
<div class="code-block">
  <div class="code-block-header">
    <span class="code-block-lang">hcl</span>
    <button type="button" class="code-copy" aria-label="Copy code">
      <span class="code-copy-icon"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><rect x="9" y="9" width="13" height="13" rx="2" ry="2"/><path d="M5 15H4a2 2 0 01-2-2V4a2 2 0 012-2h9a2 2 0 012 2v1"/></svg>
</span>
      <span class="code-copy-icon code-copy-icon-check"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><polyline points="20 6 9 17 4 12"/></svg>
</span>
      <span class="code-copy-label">Copy</span>
    </button>
  </div>
  <div class="highlight"><pre tabindex="0" class="chroma"><code class="language-hcl" data-lang="hcl"><span class="line"><span class="cl"><span class="k">provider</span> <span class="s2">&#34;aws&#34;</span> {
</span></span><span class="line"><span class="cl"><span class="n">  alias</span>  <span class="o">=</span> <span class="s2">&#34;use1&#34;</span>
</span></span><span class="line"><span class="cl"><span class="n">  region</span> <span class="o">=</span> <span class="s2">&#34;us-east-1&#34;</span>
</span></span><span class="line"><span class="cl">}
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="k">provider</span> <span class="s2">&#34;aws&#34;</span> {
</span></span><span class="line"><span class="cl"><span class="n">  alias</span>  <span class="o">=</span> <span class="s2">&#34;euw1&#34;</span>
</span></span><span class="line"><span class="cl"><span class="n">  region</span> <span class="o">=</span> <span class="s2">&#34;eu-west-1&#34;</span>
</span></span><span class="line"><span class="cl">}</span></span></code></pre></div>
</div>
<p>The NLB, its ALB-type target group, and the endpoint service that sits in
front of it:</p>
<div class="code-block">
  <div class="code-block-header">
    <span class="code-block-lang">hcl</span>
    <button type="button" class="code-copy" aria-label="Copy code">
      <span class="code-copy-icon"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><rect x="9" y="9" width="13" height="13" rx="2" ry="2"/><path d="M5 15H4a2 2 0 01-2-2V4a2 2 0 012-2h9a2 2 0 012 2v1"/></svg>
</span>
      <span class="code-copy-icon code-copy-icon-check"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><polyline points="20 6 9 17 4 12"/></svg>
</span>
      <span class="code-copy-label">Copy</span>
    </button>
  </div>
  <div class="highlight"><pre tabindex="0" class="chroma"><code class="language-hcl" data-lang="hcl"><span class="line"><span class="cl"><span class="k">resource</span> <span class="s2">&#34;aws_lb_target_group&#34; &#34;nlb_to_alb&#34;</span> {
</span></span><span class="line"><span class="cl"><span class="n">  provider</span>    <span class="o">=</span> <span class="k">aws</span><span class="p">.</span><span class="k">use1</span>
</span></span><span class="line"><span class="cl"><span class="n">  name</span>        <span class="o">=</span> <span class="s2">&#34;us-services-nlb-tg&#34;</span>
</span></span><span class="line"><span class="cl"><span class="n">  target_type</span> <span class="o">=</span> <span class="s2">&#34;alb&#34;</span>
</span></span><span class="line"><span class="cl"><span class="n">  port</span>        <span class="o">=</span> <span class="m">443</span>
</span></span><span class="line"><span class="cl"><span class="n">  protocol</span>    <span class="o">=</span> <span class="s2">&#34;TCP&#34;</span>
</span></span><span class="line"><span class="cl"><span class="n">  vpc_id</span>      <span class="o">=</span> <span class="k">var</span><span class="p">.</span><span class="k">use1_vpc_id</span>
</span></span><span class="line"><span class="cl">}
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="k">resource</span> <span class="s2">&#34;aws_lb_target_group_attachment&#34; &#34;nlb_to_alb&#34;</span> {
</span></span><span class="line"><span class="cl"><span class="n">  provider</span>          <span class="o">=</span> <span class="k">aws</span><span class="p">.</span><span class="k">use1</span>
</span></span><span class="line"><span class="cl"><span class="n">  target_group_arn</span>  <span class="o">=</span> <span class="k">aws_lb_target_group</span><span class="p">.</span><span class="k">nlb_to_alb</span><span class="p">.</span><span class="k">arn</span>
</span></span><span class="line"><span class="cl"><span class="n">  target_id</span>         <span class="o">=</span> <span class="k">aws_lb</span><span class="p">.</span><span class="k">us_internal_alb</span><span class="p">.</span><span class="k">arn</span>
</span></span><span class="line"><span class="cl"><span class="n">  port</span>              <span class="o">=</span> <span class="m">443</span>
</span></span><span class="line"><span class="cl">}
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="k">resource</span> <span class="s2">&#34;aws_lb&#34; &#34;us_nlb&#34;</span> {
</span></span><span class="line"><span class="cl"><span class="n">  provider</span>           <span class="o">=</span> <span class="k">aws</span><span class="p">.</span><span class="k">use1</span>
</span></span><span class="line"><span class="cl"><span class="n">  name</span>               <span class="o">=</span> <span class="s2">&#34;us-services-nlb&#34;</span>
</span></span><span class="line"><span class="cl"><span class="n">  internal</span>           <span class="o">=</span> <span class="kt">true</span>
</span></span><span class="line"><span class="cl"><span class="n">  load_balancer_type</span> <span class="o">=</span> <span class="s2">&#34;network&#34;</span>
</span></span><span class="line"><span class="cl"><span class="n">  subnets</span>            <span class="o">=</span> <span class="k">var</span><span class="p">.</span><span class="k">use1_private_subnet_ids</span>
</span></span><span class="line"><span class="cl">}
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="k">resource</span> <span class="s2">&#34;aws_vpc_endpoint_service&#34; &#34;us_services&#34;</span> {
</span></span><span class="line"><span class="cl"><span class="n">  provider</span>                   <span class="o">=</span> <span class="k">aws</span><span class="p">.</span><span class="k">use1</span>
</span></span><span class="line"><span class="cl"><span class="n">  acceptance_required</span>        <span class="o">=</span> <span class="kt">true</span>
</span></span><span class="line"><span class="cl"><span class="n">  network_load_balancer_arns</span> <span class="o">=</span> <span class="p">[</span><span class="k">aws_lb</span><span class="p">.</span><span class="k">us_nlb</span><span class="p">.</span><span class="k">arn</span><span class="p">]</span>
</span></span><span class="line"><span class="cl"><span class="n">  supported_regions</span>          <span class="o">=</span> <span class="p">[</span><span class="s2">&#34;eu-west-1&#34;</span><span class="p">]</span>
</span></span><span class="line"><span class="cl">}</span></span></code></pre></div>
</div>
<p><code>us_internal_alb</code> is the ALB with a <code>lambda</code>-type target group in front of
the US Lambda. I&rsquo;m leaving that part out since it&rsquo;s just the standard
ALB-to-Lambda setup, nothing specific to the region-crossing part.</p>
<h2 id="setting-up-the-eu-side">Setting up the EU side</h2>
<p>The interface endpoint is the only new thing on this side, and it needs
<code>service_region</code> set to tell it the service lives elsewhere:</p>
<div class="code-block">
  <div class="code-block-header">
    <span class="code-block-lang">hcl</span>
    <button type="button" class="code-copy" aria-label="Copy code">
      <span class="code-copy-icon"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><rect x="9" y="9" width="13" height="13" rx="2" ry="2"/><path d="M5 15H4a2 2 0 01-2-2V4a2 2 0 012-2h9a2 2 0 012 2v1"/></svg>
</span>
      <span class="code-copy-icon code-copy-icon-check"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><polyline points="20 6 9 17 4 12"/></svg>
</span>
      <span class="code-copy-label">Copy</span>
    </button>
  </div>
  <div class="highlight"><pre tabindex="0" class="chroma"><code class="language-hcl" data-lang="hcl"><span class="line"><span class="cl"><span class="k">resource</span> <span class="s2">&#34;aws_security_group&#34; &#34;us_services_endpoint&#34;</span> {
</span></span><span class="line"><span class="cl"><span class="n">  provider</span> <span class="o">=</span> <span class="k">aws</span><span class="p">.</span><span class="k">euw1</span>
</span></span><span class="line"><span class="cl"><span class="n">  name</span>     <span class="o">=</span> <span class="s2">&#34;us-services-endpoint-sg&#34;</span>
</span></span><span class="line"><span class="cl"><span class="n">  vpc_id</span>   <span class="o">=</span> <span class="k">var</span><span class="p">.</span><span class="k">euw1_vpc_id</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl">  <span class="k">ingress</span> {
</span></span><span class="line"><span class="cl"><span class="n">    from_port</span>       <span class="o">=</span> <span class="m">443</span>
</span></span><span class="line"><span class="cl"><span class="n">    to_port</span>         <span class="o">=</span> <span class="m">443</span>
</span></span><span class="line"><span class="cl"><span class="n">    protocol</span>        <span class="o">=</span> <span class="s2">&#34;tcp&#34;</span>
</span></span><span class="line"><span class="cl"><span class="n">    security_groups</span> <span class="o">=</span> <span class="p">[</span><span class="k">aws_security_group</span><span class="p">.</span><span class="k">euw1_lambda</span><span class="p">.</span><span class="k">id</span><span class="p">]</span>
</span></span><span class="line"><span class="cl">  }
</span></span><span class="line"><span class="cl">}
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="k">resource</span> <span class="s2">&#34;aws_vpc_endpoint&#34; &#34;us_services&#34;</span> {
</span></span><span class="line"><span class="cl"><span class="n">  provider</span>            <span class="o">=</span> <span class="k">aws</span><span class="p">.</span><span class="k">euw1</span>
</span></span><span class="line"><span class="cl"><span class="n">  vpc_id</span>              <span class="o">=</span> <span class="k">var</span><span class="p">.</span><span class="k">euw1_vpc_id</span>
</span></span><span class="line"><span class="cl"><span class="n">  service_name</span>        <span class="o">=</span> <span class="k">aws_vpc_endpoint_service</span><span class="p">.</span><span class="k">us_services</span><span class="p">.</span><span class="k">service_name</span>
</span></span><span class="line"><span class="cl"><span class="n">  service_region</span>      <span class="o">=</span> <span class="s2">&#34;us-east-1&#34;</span>
</span></span><span class="line"><span class="cl"><span class="n">  vpc_endpoint_type</span>   <span class="o">=</span> <span class="s2">&#34;Interface&#34;</span>
</span></span><span class="line"><span class="cl"><span class="n">  subnet_ids</span>          <span class="o">=</span> <span class="k">var</span><span class="p">.</span><span class="k">euw1_private_subnet_ids</span>
</span></span><span class="line"><span class="cl"><span class="n">  security_group_ids</span>  <span class="o">=</span> <span class="p">[</span><span class="k">aws_security_group</span><span class="p">.</span><span class="k">us_services_endpoint</span><span class="p">.</span><span class="k">id</span><span class="p">]</span>
</span></span><span class="line"><span class="cl"><span class="n">  private_dns_enabled</span> <span class="o">=</span> <span class="kt">false</span>
</span></span><span class="line"><span class="cl">}</span></span></code></pre></div>
</div>
<p>I left <code>private_dns_enabled</code> off. Private DNS on a cross-region endpoint
means resolving the target service&rsquo;s own domain name to the local endpoint,
and I didn&rsquo;t want to lean on that working the same as it does in-region.
The EU Lambda calls the endpoint&rsquo;s own generated DNS name directly instead,
which is one extra environment variable, not a real cost.</p>
<p>The endpoint connection needs to be accepted on the <code>use1</code> side before
traffic flows, because <code>acceptance_required</code> is <code>true</code> above:</p>
<div class="code-block">
  <div class="code-block-header">
    <span class="code-block-lang">shell</span>
    <button type="button" class="code-copy" aria-label="Copy code">
      <span class="code-copy-icon"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><rect x="9" y="9" width="13" height="13" rx="2" ry="2"/><path d="M5 15H4a2 2 0 01-2-2V4a2 2 0 012-2h9a2 2 0 012 2v1"/></svg>
</span>
      <span class="code-copy-icon code-copy-icon-check"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><polyline points="20 6 9 17 4 12"/></svg>
</span>
      <span class="code-copy-label">Copy</span>
    </button>
  </div>
  <div class="highlight"><pre tabindex="0" class="chroma"><code class="language-shell" data-lang="shell"><span class="line"><span class="cl">❯ aws ec2 describe-vpc-endpoint-connections --region us-east-1 <span class="se">\
</span></span></span><span class="line"><span class="cl">  --filters <span class="nv">Name</span><span class="o">=</span>vpc-endpoint-state,Values<span class="o">=</span>pendingAcceptance
</span></span><span class="line"><span class="cl"><span class="o">{</span>
</span></span><span class="line"><span class="cl">    <span class="s2">&#34;VpcEndpointConnections&#34;</span>: <span class="o">[</span>
</span></span><span class="line"><span class="cl">        <span class="o">{</span>
</span></span><span class="line"><span class="cl">            <span class="s2">&#34;ServiceId&#34;</span>: <span class="s2">&#34;vpce-svc-0a1b2c3d4e5f6a7b8&#34;</span>,
</span></span><span class="line"><span class="cl">            <span class="s2">&#34;VpcEndpointId&#34;</span>: <span class="s2">&#34;vpce-0f1e2d3c4b5a69788&#34;</span>,
</span></span><span class="line"><span class="cl">            <span class="s2">&#34;VpcEndpointOwner&#34;</span>: <span class="s2">&#34;123456789012&#34;</span>,
</span></span><span class="line"><span class="cl">            <span class="s2">&#34;VpcEndpointState&#34;</span>: <span class="s2">&#34;pendingAcceptance&#34;</span>
</span></span><span class="line"><span class="cl">        <span class="o">}</span>
</span></span><span class="line"><span class="cl">    <span class="o">]</span>
</span></span><span class="line"><span class="cl"><span class="o">}</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl">❯ aws ec2 accept-vpc-endpoint-connections --region us-east-1 <span class="se">\
</span></span></span><span class="line"><span class="cl">  --service-id vpce-svc-0a1b2c3d4e5f6a7b8 <span class="se">\
</span></span></span><span class="line"><span class="cl">  --vpc-endpoint-ids vpce-0f1e2d3c4b5a69788</span></span></code></pre></div>
</div>
<p>Same account on both ends here, so I did that by hand once. A recurring
setup would script it or add the accepter as its own Terraform resource.</p>
<h2 id="conclusion">Conclusion</h2>
<p>The stack is more layers than I&rsquo;d like for one app calling another region,
NLB, ALB, endpoint service, interface endpoint, but each layer is there
because something upstream of it can&rsquo;t do the next hop itself. It stays a
narrow, named exception instead of a general path between regions, which was
the actual requirement. If this app ever turns into &ldquo;several apps need
this,&rdquo; I&rsquo;d stop reaching for one-off endpoints and go read up on Transit
Gateway properly instead.</p>
]]></content:encoded></item><item><title>Scrummy: From Redis and Laravel to Cloudflare Durable Objects</title><link>https://omegion.dev/2026/03/scrummy-from-redis-and-laravel-to-cloudflare-durable-objects/</link><pubDate>Thu, 05 Mar 2026 00:00:00 +0000</pubDate><guid>https://omegion.dev/2026/03/scrummy-from-redis-and-laravel-to-cloudflare-durable-objects/</guid><description>Why I moved Scrummy&amp;rsquo;s planning poker backend off a Laravel app and a Redis-backed websocket server, and rebuilt it as one Cloudflare Durable Object per room.</description><content:encoded><![CDATA[<h2 id="introduction">Introduction</h2>
<p>My team used to run sprint planning with a couple of the existing planning
poker tools out there. None of them were bad, exactly, they were just built
for someone else&rsquo;s team: too many settings we didn&rsquo;t need, too few of the
ones we did. At some point that turns into an itch, and I built
<a href="https://scrummy.dev" target="_blank" rel="noopener noreferrer">Scrummy</a>
 to scratch it, a free planning poker room
with no accounts and no setup, tuned to how my own team actually plays.
<a href="https://github.com/scrummmy/scrummy" target="_blank" rel="noopener noreferrer">The code</a>
 is open source, if you
want more than the snippets below.</p>
<a class="link-row-ext project-card" href="/projects/scrummy/">
  <img class="project-card-logo" src="/projects/scrummy/logo_hu_a7e813cfe27967e8.png" width="128" height="128" alt="">
  <div class="link-row-ext-inner">
    <span class="project-card-title" style="view-transition-name: vt-title-dc80641aac2807f5a22997788f494705">Scrummy</span>
    <div class="project-card-meta">Launched September 2022</div>
    <div class="link-row-desc">Scrum sizing game for remote teams.</div>
  </div>
  <span class="ext-arrow">&#8599;</span>
</a>
<p>The first version worked. It also ran on more infrastructure than a &ldquo;type
in a URL, vote on a card, see the average&rdquo; app should need.</p>
<h2 id="the-first-version-was-overkill">The first version was overkill</h2>
<p>Scrummy started as a Laravel app with a Postgres database, real user
accounts, and teams you&rsquo;d invite people to, plus a separate Redis-backed
websocket server pushing votes around a room in real time. I&rsquo;d built it as
sign-up-and-invite software, then realized nobody wants to create an
account just to vote on a story point with their team. So I dropped the
whole auth and teams layer: no accounts, just a nickname and a room link,
and anyone who has the link can join.</p>
<p>That decision made the rest obvious. If nothing needs to persist past a
room&rsquo;s lifetime, the app doesn&rsquo;t need a traditional server-plus-database
stack running underneath it either.</p>
<h2 id="one-durable-object-per-room">One Durable Object per room</h2>
<p>The rebuild replaced all of it with Cloudflare Workers and a single
<a href="https://developers.cloudflare.com/durable-objects/" target="_blank" rel="noopener noreferrer">Durable Object</a>
 per
room.</p>
<p>If you haven&rsquo;t used one before, picture a Durable Object as a single
storage bucket that lives in one specific place on Cloudflare&rsquo;s network,
not copied across every point of presence (PoP) the way a normal Worker
request is. A regular Worker runs wherever the request happens to land,
closest to whoever&rsquo;s asking, and remembers nothing between requests. A
Durable Object is the opposite: you give it an ID, and every request for
that ID gets routed to that same one instance, wherever it actually lives,
so it can hold state in memory and handle requests one at a time instead
of two copies of itself stepping on each other.</p>
<p>For Scrummy, that ID is the room&rsquo;s slug. A Worker routes an incoming
WebSocket request to the Durable Object for that slug:</p>
<div class="code-block">
  <div class="code-block-header">
    <span class="code-block-lang">typescript</span>
    <button type="button" class="code-copy" aria-label="Copy code">
      <span class="code-copy-icon"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><rect x="9" y="9" width="13" height="13" rx="2" ry="2"/><path d="M5 15H4a2 2 0 01-2-2V4a2 2 0 012-2h9a2 2 0 012 2v1"/></svg>
</span>
      <span class="code-copy-icon code-copy-icon-check"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><polyline points="20 6 9 17 4 12"/></svg>
</span>
      <span class="code-copy-label">Copy</span>
    </button>
  </div>
  <div class="highlight"><pre tabindex="0" class="chroma"><code class="language-typescript" data-lang="typescript"><span class="line"><span class="cl"><span class="kr">const</span> <span class="nx">id</span> <span class="o">=</span> <span class="nx">env</span><span class="p">.</span><span class="nx">ROOM</span><span class="p">.</span><span class="nx">idFromName</span><span class="p">(</span><span class="nx">slug</span><span class="p">);</span>
</span></span><span class="line"><span class="cl"><span class="kr">const</span> <span class="nx">stub</span> <span class="o">=</span> <span class="nx">env</span><span class="p">.</span><span class="nx">ROOM</span><span class="p">.</span><span class="kr">get</span><span class="p">(</span><span class="nx">id</span><span class="p">);</span>
</span></span><span class="line"><span class="cl"><span class="k">return</span> <span class="nx">stub</span><span class="p">.</span><span class="nx">fetch</span><span class="p">(</span><span class="nx">request</span><span class="p">);</span></span></span></code></pre></div>
</div>
<p><code>idFromName</code> derives that ID from the slug itself, so the routing needs no
lookup table of its own; the same slug always maps to the same object.
There&rsquo;s no separate process to keep alive between rooms, and no shared
database row two rooms could collide on. Each room is its own small,
isolated unit of state.</p>
<p>Wiring it up is a few lines in <code>wrangler.jsonc</code>:</p>
<div class="code-block">
  <div class="code-block-header">
    <span class="code-block-lang">jsonc</span>
    <button type="button" class="code-copy" aria-label="Copy code">
      <span class="code-copy-icon"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><rect x="9" y="9" width="13" height="13" rx="2" ry="2"/><path d="M5 15H4a2 2 0 01-2-2V4a2 2 0 012-2h9a2 2 0 012 2v1"/></svg>
</span>
      <span class="code-copy-icon code-copy-icon-check"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><polyline points="20 6 9 17 4 12"/></svg>
</span>
      <span class="code-copy-label">Copy</span>
    </button>
  </div>
  <div class="highlight"><pre tabindex="0" class="chroma"><code class="language-jsonc" data-lang="jsonc"><span class="line"><span class="cl"><span class="s2">&#34;durable_objects&#34;</span><span class="err">:</span> <span class="p">{</span>
</span></span><span class="line"><span class="cl">  <span class="nt">&#34;bindings&#34;</span><span class="p">:</span> <span class="p">[{</span> <span class="nt">&#34;name&#34;</span><span class="p">:</span> <span class="s2">&#34;ROOM&#34;</span><span class="p">,</span> <span class="nt">&#34;class_name&#34;</span><span class="p">:</span> <span class="s2">&#34;Room&#34;</span> <span class="p">}]</span>
</span></span><span class="line"><span class="cl"><span class="p">}</span><span class="err">,</span>
</span></span><span class="line"><span class="cl"><span class="s2">&#34;migrations&#34;</span><span class="err">:</span> <span class="p">[{</span> <span class="nt">&#34;tag&#34;</span><span class="p">:</span> <span class="s2">&#34;v1&#34;</span><span class="p">,</span> <span class="nt">&#34;new_sqlite_classes&#34;</span><span class="p">:</span> <span class="p">[</span><span class="s2">&#34;Room&#34;</span><span class="p">]</span> <span class="p">}]</span></span></span></code></pre></div>
</div>
<h2 id="websockets-that-dont-cost-anything-while-idle">Websockets that don&rsquo;t cost anything while idle</h2>
<p>The part that replaced the Redis server is the
<a href="https://developers.cloudflare.com/durable-objects/best-practices/websockets/" target="_blank" rel="noopener noreferrer">WebSocket Hibernation API</a>
.
A Durable Object can accept a WebSocket connection and then hibernate
between messages, holding no memory or compute open, and wake back up when
one arrives:</p>
<div class="code-block">
  <div class="code-block-header">
    <span class="code-block-lang">typescript</span>
    <button type="button" class="code-copy" aria-label="Copy code">
      <span class="code-copy-icon"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><rect x="9" y="9" width="13" height="13" rx="2" ry="2"/><path d="M5 15H4a2 2 0 01-2-2V4a2 2 0 012-2h9a2 2 0 012 2v1"/></svg>
</span>
      <span class="code-copy-icon code-copy-icon-check"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><polyline points="20 6 9 17 4 12"/></svg>
</span>
      <span class="code-copy-label">Copy</span>
    </button>
  </div>
  <div class="highlight"><pre tabindex="0" class="chroma"><code class="language-typescript" data-lang="typescript"><span class="line"><span class="cl"><span class="kr">async</span> <span class="nx">fetch</span><span class="p">(</span><span class="nx">request</span>: <span class="kt">Request</span><span class="p">)</span><span class="o">:</span> <span class="nx">Promise</span><span class="p">&lt;</span><span class="nt">Response</span><span class="p">&gt;</span> <span class="p">{</span>
</span></span><span class="line"><span class="cl">  <span class="kr">const</span> <span class="nx">pair</span> <span class="o">=</span> <span class="k">new</span> <span class="nx">WebSocketPair</span><span class="p">();</span>
</span></span><span class="line"><span class="cl">  <span class="kr">const</span> <span class="p">[</span><span class="nx">client</span><span class="p">,</span> <span class="nx">server</span><span class="p">]</span> <span class="o">=</span> <span class="nb">Object</span><span class="p">.</span><span class="nx">values</span><span class="p">(</span><span class="nx">pair</span><span class="p">);</span>
</span></span><span class="line"><span class="cl">  <span class="k">this</span><span class="p">.</span><span class="nx">ctx</span><span class="p">.</span><span class="nx">acceptWebSocket</span><span class="p">(</span><span class="nx">server</span><span class="p">);</span>
</span></span><span class="line"><span class="cl">  <span class="k">return</span> <span class="k">new</span> <span class="nx">Response</span><span class="p">(</span><span class="kc">null</span><span class="p">,</span> <span class="p">{</span> <span class="nx">status</span>: <span class="kt">101</span><span class="p">,</span> <span class="nx">webSocket</span>: <span class="kt">client</span> <span class="p">});</span>
</span></span><span class="line"><span class="cl"><span class="p">}</span></span></span></code></pre></div>
</div>
<p>State that needs to survive hibernation goes into the Durable Object&rsquo;s own
SQLite storage, and each socket carries its user&rsquo;s identity with
<code>ws.serializeAttachment</code>, so a room that wakes back up after sitting idle
for a day still knows who&rsquo;s who without a database lookup. A 30-day alarm
handles cleanup: if nobody&rsquo;s visited a room in a month, the object deletes
its own storage and disappears.</p>
<p>The full <code>Room</code> object, including how it handles joins, votes, and a
short reconnect grace period for someone who just refreshed the page, is
in <a href="https://github.com/scrummmy/scrummy/blob/master/worker/room.ts" target="_blank" rel="noopener noreferrer"><code>worker/room.ts</code></a>

on GitHub.</p>
<h2 id="why-not-just-a-database">Why not just a database</h2>
<p>A database would work here too, it&rsquo;s just the wrong tool for this. The
thing Scrummy needs on every vote is simple: tell everyone else in the
room. The old stack meant a separate Node process fanning out socket
events, with Redis and Postgres nearby for everything else. With a
Durable Object, the Worker already holds every open socket for that room
in memory, so broadcasting a vote is a loop over connections it already
has, not a hop through another service:</p>
<div class="code-block">
  <div class="code-block-header">
    <span class="code-block-lang">typescript</span>
    <button type="button" class="code-copy" aria-label="Copy code">
      <span class="code-copy-icon"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><rect x="9" y="9" width="13" height="13" rx="2" ry="2"/><path d="M5 15H4a2 2 0 01-2-2V4a2 2 0 012-2h9a2 2 0 012 2v1"/></svg>
</span>
      <span class="code-copy-icon code-copy-icon-check"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><polyline points="20 6 9 17 4 12"/></svg>
</span>
      <span class="code-copy-label">Copy</span>
    </button>
  </div>
  <div class="highlight"><pre tabindex="0" class="chroma"><code class="language-typescript" data-lang="typescript"><span class="line"><span class="cl"><span class="kr">private</span> <span class="kr">async</span> <span class="nx">broadcast</span><span class="p">(</span><span class="nx">message</span>: <span class="kt">ServerMessage</span><span class="p">)</span><span class="o">:</span> <span class="nx">Promise</span><span class="p">&lt;</span><span class="nt">void</span><span class="p">&gt;</span> <span class="p">{</span>
</span></span><span class="line"><span class="cl">  <span class="kr">const</span> <span class="nx">payload</span> <span class="o">=</span> <span class="nx">JSON</span><span class="p">.</span><span class="nx">stringify</span><span class="p">(</span><span class="nx">message</span><span class="p">);</span>
</span></span><span class="line"><span class="cl">  <span class="k">for</span> <span class="p">(</span><span class="kr">const</span> <span class="nx">ws</span> <span class="k">of</span> <span class="k">this</span><span class="p">.</span><span class="nx">ctx</span><span class="p">.</span><span class="nx">getWebSockets</span><span class="p">())</span> <span class="p">{</span>
</span></span><span class="line"><span class="cl">    <span class="k">if</span> <span class="p">(</span><span class="nx">ws</span><span class="p">.</span><span class="nx">readyState</span> <span class="o">===</span> <span class="nx">WebSocket</span><span class="p">.</span><span class="nx">OPEN</span><span class="p">)</span> <span class="nx">ws</span><span class="p">.</span><span class="nx">send</span><span class="p">(</span><span class="nx">payload</span><span class="p">);</span>
</span></span><span class="line"><span class="cl">  <span class="p">}</span>
</span></span><span class="line"><span class="cl"><span class="p">}</span></span></span></code></pre></div>
</div>
<p>Storage only comes into it to survive hibernation, a restart, or the
Durable Object migrating between machines. Coordinating the people
in the room doesn&rsquo;t touch it at all.</p>
<h2 id="conclusion">Conclusion</h2>
<p>I wouldn&rsquo;t reach for a Durable Object for most of what Redis is good at.
But for state scoped to a small group of people, that lives for a few
days and needs to reach everyone watching the instant it changes, one
object per room turned out to be a better match than a server and a
database ever were, and it&rsquo;s one less thing I have to keep running.</p>
<p>Try it yourself at <a href="https://scrummy.dev" target="_blank" rel="noopener noreferrer">scrummy.dev</a>
. If something&rsquo;s
missing or you&rsquo;d build a piece of it differently, open an issue or a PR
on <a href="https://github.com/scrummmy/scrummy" target="_blank" rel="noopener noreferrer">GitHub</a>
.</p>
]]></content:encoded></item><item><title>Proxmox Homelab: Exposing Portainer Apps with Tailscale</title><link>https://omegion.dev/2026/01/proxmox-homelab-exposing-portainer-apps-with-tailscale/</link><pubDate>Thu, 22 Jan 2026 00:00:00 +0000</pubDate><guid>https://omegion.dev/2026/01/proxmox-homelab-exposing-portainer-apps-with-tailscale/</guid><description>Deploying tsdproxy through Portainer to expose individual Docker containers to a Tailscale tailnet, without opening anything to the public internet.</description><content:encoded><![CDATA[<h2 id="introduction">Introduction</h2>
<p>Between the <a href="https://omegion.dev/2025/09/proxmox-homelab-vms-and-portainer-with-terraform/">Terraform-managed VM</a>

and <a href="https://omegion.dev/2025/11/proxmox-homelab-backing-up-vms-to-a-nas/">backups landing on the NAS</a>
,
the Portainer container is solid, but everything on it is LAN-only. To check on
it, or hit an app running behind it, I have to be home. This post gets
remote access working without opening anything to the public internet -
<a href="https://tailscale.com/" target="_blank" rel="noopener noreferrer">Tailscale</a>
 instead.</p>
<h2 id="why-tailscale-here-not-a-public-tunnel">Why Tailscale Here, Not a Public Tunnel</h2>
<p>I already wrote about <a href="https://omegion.dev/2024/09/talos-os-exposing-a-homelab-service-with-cloudflare-tunnel/">exposing a service through a Cloudflare Tunnel</a>

gated by <a href="https://omegion.dev/2025/06/talos-os-securing-an-exposed-app-with-cloudflare-zero-trust/">Zero Trust identity</a>
.
That&rsquo;s the right tool when something needs to be reachable by anyone with
the right login. This isn&rsquo;t that - it&rsquo;s just me, wanting to reach my own
homelab from my own devices. Tailscale builds a private mesh network
(WireGuard under the hood) between devices I&rsquo;ve enrolled (registering a machine to Tailnet) myself; nothing
gets a public hostname or a public IP at all, so there&rsquo;s no login screen
to gate because there&rsquo;s no public entry point to gate in the first place.</p>
<h2 id="prerequisites">Prerequisites</h2>
<ol>
<li>The Proxmox VM and Portainer setup from the
<a href="https://omegion.dev/2025/09/proxmox-homelab-vms-and-portainer-with-terraform/">first post</a>
.</li>
<li>A <a href="https://tailscale.com/" target="_blank" rel="noopener noreferrer">Tailscale</a>
 account and tailnet.</li>
<li>A reusable Tailscale auth key (<em>Settings &gt; Keys &gt; Generate auth key</em>,
tick &ldquo;Reusable&rdquo; - a plain key from a logged-in session, not an
OAuth-client key, which Tailscale requires to be tagged).</li>
</ol>
<h2 id="what-tsdproxy-does">What tsdproxy Does</h2>
<p><a href="https://github.com/almeidapaulopt/tsdproxy" target="_blank" rel="noopener noreferrer">tsdproxy</a>
 watches the Docker
socket and, for containers with the right labels, registers each one as
its own node on the tailnet with its own hostname - opt-in per container,
rather than one subnet router exposing the whole LAN at once. That fits
Portainer well: I add a label to whatever stack I want reachable, and
nothing else on the box is affected.</p>
<h2 id="the-tsdproxy-stack">The tsdproxy Stack</h2>
<p>I added this the same way as everything else in this series: another
entry in <code>services</code>, deployed as its own <code>portainer_stack</code> by Terraform,
not clicked together in the Portainer UI. Rather than paste the compose
file here and let it drift from what I actually run, the real one lives
in the repo from the <a href="https://omegion.dev/2025/09/proxmox-homelab-vms-and-portainer-with-terraform/">first post</a>
:
<a href="https://github.com/omegion/proxmox-portainer-example/blob/main/templates/tsdproxy.yaml.tftpl" target="_blank" rel="noopener noreferrer"><code>templates/tsdproxy.yaml.tftpl</code></a>
.
The parts worth calling out:</p>
<ul>
<li><code>TSDPROXY_AUTHKEY</code> only seeds <code>/config/tsdproxy.yaml</code> on tsdproxy&rsquo;s
<em>first</em> boot - later restarts read the config file it already wrote,
not the env var again.</li>
<li>tsdproxy isn&rsquo;t on the same Docker network as the containers it proxies
(every Portainer stack gets its own default network), so it reaches
them over <code>host.docker.internal</code> at whatever host port they&rsquo;ve
published - <code>extra_hosts: host.docker.internal:host-gateway</code> is what
makes that resolve. This is tsdproxy&rsquo;s documented pattern for exactly
this situation - two containers in separate Compose stacks.</li>
</ul>
<p>Wiring it in is one <code>services</code> entry in <code>terraform.tfvars</code>:</p>
<div class="code-block">
  <div class="code-block-header">
    <span class="code-block-lang">hcl</span>
    <button type="button" class="code-copy" aria-label="Copy code">
      <span class="code-copy-icon"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><rect x="9" y="9" width="13" height="13" rx="2" ry="2"/><path d="M5 15H4a2 2 0 01-2-2V4a2 2 0 012-2h9a2 2 0 012 2v1"/></svg>
</span>
      <span class="code-copy-icon code-copy-icon-check"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><polyline points="20 6 9 17 4 12"/></svg>
</span>
      <span class="code-copy-label">Copy</span>
    </button>
  </div>
  <div class="highlight"><pre tabindex="0" class="chroma"><code class="language-hcl" data-lang="hcl"><span class="line"><span class="cl"><span class="n">services</span> <span class="o">=</span> <span class="p">[</span><span class="c1">
</span></span></span><span class="line"><span class="cl"><span class="c1">  # ... the whoami stack from the first post ...
</span></span></span><span class="line"><span class="cl">  {
</span></span><span class="line"><span class="cl"><span class="n">    name</span>         <span class="o">=</span> <span class="s2">&#34;tsdproxy&#34;</span>
</span></span><span class="line"><span class="cl"><span class="n">    compose_file</span> <span class="o">=</span> <span class="s2">&#34;tsdproxy.yaml.tftpl&#34;</span>
</span></span><span class="line"><span class="cl"><span class="n">    env</span> <span class="o">=</span> {
</span></span><span class="line"><span class="cl"><span class="n">      TS_AUTHKEY</span> <span class="o">=</span> <span class="s2">&#34;&lt;your reusable Tailscale auth key&gt;&#34;</span>
</span></span><span class="line"><span class="cl">    }
</span></span><span class="line"><span class="cl">  }<span class="p">,</span>
</span></span><span class="line"><span class="cl"><span class="p">]</span></span></span></code></pre></div>
</div>
<div class="code-block">
  <div class="code-block-header">
    <span class="code-block-lang">shell</span>
    <button type="button" class="code-copy" aria-label="Copy code">
      <span class="code-copy-icon"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><rect x="9" y="9" width="13" height="13" rx="2" ry="2"/><path d="M5 15H4a2 2 0 01-2-2V4a2 2 0 012-2h9a2 2 0 012 2v1"/></svg>
</span>
      <span class="code-copy-icon code-copy-icon-check"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><polyline points="20 6 9 17 4 12"/></svg>
</span>
      <span class="code-copy-label">Copy</span>
    </button>
  </div>
  <div class="highlight"><pre tabindex="0" class="chroma"><code class="language-shell" data-lang="shell"><span class="line"><span class="cl">❯ terraform apply
</span></span><span class="line"><span class="cl">...
</span></span><span class="line"><span class="cl">Apply complete! Resources: <span class="m">1</span> added, <span class="m">0</span> changed, <span class="m">0</span> destroyed.</span></span></code></pre></div>
</div>
<pre class="mermaid" id="mermaid-2"></pre>
<script>(window.__mermaidSources ||= {})["mermaid-2"] = "flowchart TD\n  Device[\"My laptop, on the tailnet\"] --\u003e Proxy[\"tsdproxy\"]\n  Proxy --\u003e App[\"whoami container\"]";</script>

<h2 id="labeling-the-dummy-app">Labeling the Dummy App</h2>
<p>tsdproxy discovers containers the same way Traefik does - two labels on
the container itself, not a separate config file per app:</p>
<div class="code-block">
  <div class="code-block-header">
    <span class="code-block-lang">yaml</span>
    <button type="button" class="code-copy" aria-label="Copy code">
      <span class="code-copy-icon"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><rect x="9" y="9" width="13" height="13" rx="2" ry="2"/><path d="M5 15H4a2 2 0 01-2-2V4a2 2 0 012-2h9a2 2 0 012 2v1"/></svg>
</span>
      <span class="code-copy-icon code-copy-icon-check"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><polyline points="20 6 9 17 4 12"/></svg>
</span>
      <span class="code-copy-label">Copy</span>
    </button>
  </div>
  <div class="highlight"><pre tabindex="0" class="chroma"><code class="language-yaml" data-lang="yaml"><span class="line"><span class="cl"><span class="nt">labels</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">  </span><span class="nt">tsdproxy.enable</span><span class="p">:</span><span class="w"> </span><span class="s2">&#34;true&#34;</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">  </span><span class="nt">tsdproxy.name</span><span class="p">:</span><span class="w"> </span><span class="s2">&#34;whoami&#34;</span></span></span></code></pre></div>
</div>
<p>I added those to the same <code>whoami</code> stack from the <a href="https://omegion.dev/2025/09/proxmox-homelab-vms-and-portainer-with-terraform/">first post</a>
 -
see the current version in
<a href="https://github.com/omegion/proxmox-portainer-example/blob/main/templates/whoami.yaml.tftpl" target="_blank" rel="noopener noreferrer"><code>templates/whoami.yaml.tftpl</code></a>
.
<code>tsdproxy.enable</code> is what gets it picked up at all; <code>tsdproxy.name</code> sets
the hostname it shows up under on the tailnet - drop it and tsdproxy falls
back to the container name. The full label reference (multi-port apps,
TCP/UDP proxying, health checks) is in
<a href="https://almeidapaulopt.github.io/tsdproxy/docs/providers/docker/" target="_blank" rel="noopener noreferrer">tsdproxy&rsquo;s docs</a>

if you need more than the two labels here.</p>
<h2 id="reaching-it-from-a-tailnet-device">Reaching It From a Tailnet Device</h2>
<p>Once the stack redeployed with that label, <code>whoami</code> shows up as its own
machine in the Tailscale admin console, with its own <code>*.ts.net</code> hostname
and a Tailscale-issued HTTPS certificate for it:</p>
<div class="code-block">
  <div class="code-block-header">
    <span class="code-block-lang">shell</span>
    <button type="button" class="code-copy" aria-label="Copy code">
      <span class="code-copy-icon"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><rect x="9" y="9" width="13" height="13" rx="2" ry="2"/><path d="M5 15H4a2 2 0 01-2-2V4a2 2 0 012-2h9a2 2 0 012 2v1"/></svg>
</span>
      <span class="code-copy-icon code-copy-icon-check"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><polyline points="20 6 9 17 4 12"/></svg>
</span>
      <span class="code-copy-label">Copy</span>
    </button>
  </div>
  <div class="highlight"><pre tabindex="0" class="chroma"><code class="language-shell" data-lang="shell"><span class="line"><span class="cl">❯ curl -s https://whoami.my-tailnet.ts.net
</span></span><span class="line"><span class="cl">Hostname: a1b2c3d4e5f6
</span></span><span class="line"><span class="cl">IP: 172.18.0.2</span></span></code></pre></div>
</div>
<p>Ran from my laptop, off the home LAN entirely, over the tailnet.</p>
<h2 id="conclusion">Conclusion</h2>
<p>The <a href="https://omegion.dev/2025/06/talos-os-securing-an-exposed-app-with-cloudflare-zero-trust/">Cloudflare Zero Trust post</a>

needed an actual identity policy - an Okta
group, a session duration, a decision about who gets in. This one skips
that entirely: nothing is reachable at all unless the requesting device is
already enrolled in my tailnet, so there&rsquo;s no access policy to configure
or get wrong. A VM built by Terraform, Portainer running stacks on it
declaratively, backups landing on the NAS on a schedule, and now remote
access without a single port opened on my router.</p>
]]></content:encoded></item><item><title>Proxmox Homelab: Backing Up VMs to a NAS</title><link>https://omegion.dev/2025/11/proxmox-homelab-backing-up-vms-to-a-nas/</link><pubDate>Fri, 14 Nov 2025 00:00:00 +0000</pubDate><guid>https://omegion.dev/2025/11/proxmox-homelab-backing-up-vms-to-a-nas/</guid><description>Adding a NAS as NFS backup storage in Proxmox VE, scheduling backups, and verifying one lands on the NAS.</description><content:encoded><![CDATA[<h2 id="introduction">Introduction</h2>
<p>The <a href="https://omegion.dev/2025/09/proxmox-homelab-vms-and-portainer-with-terraform/">previous post</a>

got me a Terraform-managed VM running Docker and Portainer. Great, except
it exists in exactly one place: the local disk of one Proxmox host. If that
disk dies, the VM, Portainer, and every stack in it go with it. This post
is getting backups off that box and onto a NAS I already have on the LAN.</p>
<h2 id="prerequisites">Prerequisites</h2>
<ol>
<li>The Proxmox host from the
<a href="https://omegion.dev/2025/09/proxmox-homelab-vms-and-portainer-with-terraform/">previous post</a>
.</li>
<li>A NAS on the same LAN that can export an
<a href="https://en.wikipedia.org/wiki/Network_File_System" target="_blank" rel="noopener noreferrer">NFS</a>
 share.</li>
</ol>
<h2 id="creating-the-nfs-share-on-the-nas">Creating the NFS Share on the NAS</h2>
<p>On the NAS, I created a shared folder for this specifically -
<code>/volume1/proxmox-backup</code> - and exported it over NFS restricted to the
Proxmox host&rsquo;s IP only, <code>192.168.1.70/32</code>, not the whole LAN. Same
principle as <a href="https://omegion.dev/2023/02/talos-os-setup-a-3-node-homelab-kubernetes-cluster/">restricting NetworkPolicy egress</a>

on the Talos cluster: nothing else on the network has a reason to write
into this share, so nothing else gets a route to it.</p>
<h2 id="adding-the-nas-as-proxmox-storage">Adding the NAS as Proxmox Storage</h2>
<p><em>Datacenter &gt; Storage &gt; Add &gt; NFS</em> in the Proxmox UI, pointed at the
export, with content type set to &ldquo;VZDump backup file&rdquo; (not disk images -
this share is backups only). That writes an entry into
<code>/etc/pve/storage.cfg</code>:</p>
<div class="code-block">
  <div class="code-block-header">
    <span class="code-block-lang">text</span>
    <button type="button" class="code-copy" aria-label="Copy code">
      <span class="code-copy-icon"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><rect x="9" y="9" width="13" height="13" rx="2" ry="2"/><path d="M5 15H4a2 2 0 01-2-2V4a2 2 0 012-2h9a2 2 0 012 2v1"/></svg>
</span>
      <span class="code-copy-icon code-copy-icon-check"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><polyline points="20 6 9 17 4 12"/></svg>
</span>
      <span class="code-copy-label">Copy</span>
    </button>
  </div>
  <pre tabindex="0"><code>nfs: nas-backup
    export /volume1/proxmox-backup
    path /mnt/pve/nas-backup
    server 192.168.1.30
    content backup
    prune-backups keep-last=7</code></pre>
</div>
<p><code>prune-backups keep-last=7</code> caps it at the last 7 backups per guest -
Proxmox deletes older ones itself on each new backup, so the NAS share
doesn&rsquo;t grow unbounded.</p>
<h2 id="scheduling-backups">Scheduling Backups</h2>
<p><em>Datacenter &gt; Backup &gt; Add</em>: target the <code>portainer</code> VM, storage
<code>nas-backup</code>, mode <code>snapshot</code> (backs up a running VM without stopping it,
using a live snapshot), compression <code>zstd</code>, schedule <code>02:00</code> daily. Mode
matters here specifically because this VM is meant to stay up - <code>stop</code> or
<code>suspend</code> mode would take Portainer and everything behind it down for the
duration of every backup.</p>
<pre class="mermaid" id="mermaid-1"></pre>
<script>(window.__mermaidSources ||= {})["mermaid-1"] = "flowchart TD\n  VM[\"portainer VM\"] --\u003e Job[\"scheduled backup job\"]\n  Job --\u003e NFS[\"NFS export\"]\n  NFS --\u003e NAS[\"NAS\"]";</script>

<h2 id="running-one-manually">Running One Manually</h2>
<p>Didn&rsquo;t want to wait until 2am to find out if this actually works:</p>
<div class="code-block">
  <div class="code-block-header">
    <span class="code-block-lang">shell</span>
    <button type="button" class="code-copy" aria-label="Copy code">
      <span class="code-copy-icon"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><rect x="9" y="9" width="13" height="13" rx="2" ry="2"/><path d="M5 15H4a2 2 0 01-2-2V4a2 2 0 012-2h9a2 2 0 012 2v1"/></svg>
</span>
      <span class="code-copy-icon code-copy-icon-check"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><polyline points="20 6 9 17 4 12"/></svg>
</span>
      <span class="code-copy-label">Copy</span>
    </button>
  </div>
  <div class="highlight"><pre tabindex="0" class="chroma"><code class="language-shell" data-lang="shell"><span class="line"><span class="cl">❯ vzdump <span class="m">100</span> --storage nas-backup --mode snapshot --compress zstd
</span></span><span class="line"><span class="cl">INFO: starting new backup job: vzdump <span class="m">100</span> --storage nas-backup --mode snapshot --compress zstd
</span></span><span class="line"><span class="cl">INFO: Starting Backup of VM <span class="m">100</span> <span class="o">(</span>qemu<span class="o">)</span>
</span></span><span class="line"><span class="cl">INFO: <span class="nv">status</span> <span class="o">=</span> running
</span></span><span class="line"><span class="cl">INFO: backup mode: snapshot
</span></span><span class="line"><span class="cl">INFO: creating vzdump archive <span class="s1">&#39;/mnt/pve/nas-backup/dump/vzdump-qemu-100-2025_11_14-02_03_11.vma.zst&#39;</span>
</span></span><span class="line"><span class="cl">INFO: Finished Backup of VM <span class="m">100</span> <span class="o">(</span>00:01:47<span class="o">)</span>
</span></span><span class="line"><span class="cl">INFO: Backup job finished successfully</span></span></code></pre></div>
</div>
<p>And confirmed the file actually landed on the NAS, not just in Proxmox&rsquo;s
view of the mount:</p>
<div class="code-block">
  <div class="code-block-header">
    <span class="code-block-lang">shell</span>
    <button type="button" class="code-copy" aria-label="Copy code">
      <span class="code-copy-icon"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><rect x="9" y="9" width="13" height="13" rx="2" ry="2"/><path d="M5 15H4a2 2 0 01-2-2V4a2 2 0 012-2h9a2 2 0 012 2v1"/></svg>
</span>
      <span class="code-copy-icon code-copy-icon-check"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><polyline points="20 6 9 17 4 12"/></svg>
</span>
      <span class="code-copy-label">Copy</span>
    </button>
  </div>
  <div class="highlight"><pre tabindex="0" class="chroma"><code class="language-shell" data-lang="shell"><span class="line"><span class="cl">❯ ssh nas <span class="s1">&#39;ls -la /volume1/proxmox-backup/dump/&#39;</span>
</span></span><span class="line"><span class="cl">-rw-r--r-- <span class="m">1</span> root root <span class="m">3812048291</span> Nov <span class="m">14</span> 02:05 vzdump-qemu-100-2025_11_14-02_03_11.vma.zst</span></span></code></pre></div>
</div>
<h2 id="conclusion">Conclusion</h2>
<p>The VM now has off-box backups on a schedule, capped at 7 generations, and
I&rsquo;ve confirmed manually that a backup actually reaches the NAS rather than
trusting the UI&rsquo;s green checkmark. It&rsquo;s still all on the same LAN, though -
if I want to check on Portainer, or hit an app running behind it, from
anywhere other than my home network, none of that is reachable yet.
<a href="https://omegion.dev/2026/01/proxmox-homelab-exposing-portainer-apps-with-tailscale/">Next post</a>
:
exposing it over Tailscale.</p>
]]></content:encoded></item><item><title>Rately</title><link>https://omegion.dev/projects/rately/</link><pubDate>Sat, 08 Nov 2025 00:00:00 +0000</pubDate><guid>https://omegion.dev/projects/rately/</guid><description>Customizable rate limiting at the edge, built to protect APIs in seconds.</description><content:encoded><![CDATA[<p>I built Rately because I couldn&rsquo;t find a rate limiter that was actually
customizable at scale. Most services give you a flat request-per-second
number and call it done — fine until your real traffic doesn&rsquo;t look like
that. With Rately you set limits based on your actual user data: per plan
tier, per geo, per endpoint, whatever the API actually needs, instead of
one number applied everywhere.</p>
]]></content:encoded></item><item><title>Proxmox Homelab: VMs and Portainer with Terraform</title><link>https://omegion.dev/2025/09/proxmox-homelab-vms-and-portainer-with-terraform/</link><pubDate>Thu, 18 Sep 2025 00:00:00 +0000</pubDate><guid>https://omegion.dev/2025/09/proxmox-homelab-vms-and-portainer-with-terraform/</guid><description>Installing Proxmox VE on a homelab box and standing up a Terraform-managed VM running Docker and Portainer, with a dummy container deployed declaratively.</description><content:encoded><![CDATA[<h2 id="introduction">Introduction</h2>
<p>Alongside the Talos Kubernetes cluster I&rsquo;ve written about, I picked up
another mini PC and wanted a general-purpose machine for the stuff that
doesn&rsquo;t need Kubernetes at all - Pi-hole, Home Assistant, that kind of
thing. Spinning up pods for every small self-hosted app felt like the
wrong tool. Proxmox VE gives me VMs and LXC containers directly on the
hardware instead, and I wanted the VM creation itself to be Terraform, not
a manual install wizard. This post is that: Proxmox installed, one VM
created by Terraform, Docker and Portainer on it, and a dummy container
deployed into Portainer the same declarative way.</p>
<h2 id="prerequisites">Prerequisites</h2>
<ol>
<li>A machine to install <a href="https://www.proxmox.com/en/proxmox-virtual-environment/overview" target="_blank" rel="noopener noreferrer">Proxmox VE</a>

on - I used a mini used Lenovo Tiny.</li>
<li><a href="https://developer.hashicorp.com/terraform/install" target="_blank" rel="noopener noreferrer">Terraform</a>
.</li>
<li>The <a href="https://registry.terraform.io/providers/bpg/proxmox/latest" target="_blank" rel="noopener noreferrer"><code>bpg/proxmox</code></a>

and <a href="https://registry.terraform.io/providers/portainer/portainer/latest" target="_blank" rel="noopener noreferrer"><code>portainer/portainer</code></a>

Terraform providers (declared in code below, nothing to install by
hand).</li>
</ol>
<h2 id="installing-proxmox">Installing Proxmox</h2>
<p>Flashed the <a href="https://www.proxmox.com/en/downloads" target="_blank" rel="noopener noreferrer">Proxmox VE ISO</a>
 to a USB
stick, installed it on the mini PC, and got the web UI at
<code>https://192.168.1.70:8006</code>. One thing Terraform genuinely cannot do for
you: create its own API credentials. Before anything else, I created a
token under <em>Datacenter &gt; Permissions &gt; API Tokens</em> - Terraform
authenticates with that token, it can&rsquo;t bootstrap it.</p>
<h2 id="creating-the-vm-with-terraform">Creating the VM with Terraform</h2>
<p>Instead of another manual ISO install for the VM itself, I import a
Debian cloud image and let cloud-init configure it - faster, and it&rsquo;s the
only way this stays fully declarative:</p>
<div class="code-block">
  <div class="code-block-header">
    <span class="code-block-lang">hcl</span>
    <button type="button" class="code-copy" aria-label="Copy code">
      <span class="code-copy-icon"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><rect x="9" y="9" width="13" height="13" rx="2" ry="2"/><path d="M5 15H4a2 2 0 01-2-2V4a2 2 0 012-2h9a2 2 0 012 2v1"/></svg>
</span>
      <span class="code-copy-icon code-copy-icon-check"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><polyline points="20 6 9 17 4 12"/></svg>
</span>
      <span class="code-copy-label">Copy</span>
    </button>
  </div>
  <div class="highlight"><pre tabindex="0" class="chroma"><code class="language-hcl" data-lang="hcl"><span class="line"><span class="cl"><span class="k">module</span> <span class="s2">&#34;portainer_vm&#34;</span> {
</span></span><span class="line"><span class="cl"><span class="n">  source</span> <span class="o">=</span> <span class="s2">&#34;./modules/vm&#34;</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="n">  node_name</span>   <span class="o">=</span> <span class="k">var</span><span class="p">.</span><span class="k">proxmox_node_name</span>
</span></span><span class="line"><span class="cl"><span class="n">  name</span>        <span class="o">=</span> <span class="s2">&#34;portainer&#34;</span>
</span></span><span class="line"><span class="cl"><span class="n">  description</span> <span class="o">=</span> <span class="s2">&#34;Docker + Portainer host - managed by Terraform&#34;</span>
</span></span><span class="line"><span class="cl"><span class="n">  tags</span>        <span class="o">=</span> <span class="p">[</span><span class="s2">&#34;terraform&#34;, &#34;portainer&#34;</span><span class="p">]</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="n">  cores</span>     <span class="o">=</span> <span class="m">4</span>
</span></span><span class="line"><span class="cl"><span class="n">  memory_mb</span> <span class="o">=</span> <span class="m">8192</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="n">  disk_datastore_id</span> <span class="o">=</span> <span class="k">var</span><span class="p">.</span><span class="k">vm_disk_datastore_id</span>
</span></span><span class="line"><span class="cl"><span class="n">  disk_size_gb</span>      <span class="o">=</span> <span class="m">40</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="n">  image_url</span>          <span class="o">=</span> <span class="s2">&#34;https://cloud.debian.org/images/cloud/trixie/latest/debian-13-generic-amd64.qcow2&#34;</span>
</span></span><span class="line"><span class="cl"><span class="n">  image_datastore_id</span> <span class="o">=</span> <span class="s2">&#34;local&#34;</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="n">  bridge</span>       <span class="o">=</span> <span class="s2">&#34;vmbr0&#34;</span>
</span></span><span class="line"><span class="cl"><span class="n">  ipv4_address</span> <span class="o">=</span> <span class="s2">&#34;${var.portainer_vm_ip}/24&#34;</span>
</span></span><span class="line"><span class="cl"><span class="n">  ipv4_gateway</span> <span class="o">=</span> <span class="k">var</span><span class="p">.</span><span class="k">lan_gateway</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="n">  username</span>        <span class="o">=</span> <span class="s2">&#34;debian&#34;</span>
</span></span><span class="line"><span class="cl"><span class="n">  ssh_public_keys</span> <span class="o">=</span> <span class="p">[</span><span class="k">trimspace</span><span class="p">(</span><span class="k">file</span><span class="p">(</span><span class="k">pathexpand</span><span class="p">(</span><span class="k">var</span><span class="p">.</span><span class="k">ssh_public_key_path</span><span class="p">)))]</span>
</span></span><span class="line"><span class="cl"><span class="n">  ssh_private_key</span> <span class="o">=</span> <span class="k">file</span><span class="p">(</span><span class="k">pathexpand</span><span class="p">(</span><span class="k">var</span><span class="p">.</span><span class="k">ssh_private_key_path</span><span class="p">))</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="n">  provision_commands</span> <span class="o">=</span> <span class="p">[</span>
</span></span><span class="line"><span class="cl">    <span class="k">templatefile</span><span class="p">(</span><span class="s2">&#34;${path.module}/templates/portainer-bootstrap.sh.tftpl&#34;</span><span class="p">,</span> {
</span></span><span class="line"><span class="cl"><span class="n">      admin_password</span> <span class="o">=</span> <span class="k">var</span><span class="p">.</span><span class="k">portainer_admin_password</span>
</span></span><span class="line"><span class="cl">    }<span class="p">),</span>
</span></span><span class="line"><span class="cl">  <span class="p">]</span>
</span></span><span class="line"><span class="cl">}</span></span></code></pre></div>
</div>
<p><code>provision_commands</code> runs over SSH once the VM is up: install Docker, run
Portainer, wait for its API to answer. One real bug from getting this
working - the bootstrap script must not <code>exit 0</code> on success. When you
concatenate more than one script into <code>provision_commands</code>, an early exit
on the first one silently skips everything after it.</p>
<pre class="mermaid" id="mermaid-1"></pre>
<script>(window.__mermaidSources ||= {})["mermaid-1"] = "flowchart TD\n  TF[\"terraform apply\"] --\u003e PVE[\"Proxmox API\"]\n  PVE --\u003e VM[\"portainer VM\"]\n  TF --\u003e PAPI[\"Portainer API\"]\n  PAPI --\u003e App[\"whoami container\"]";</script>

<div class="code-block">
  <div class="code-block-header">
    <span class="code-block-lang">shell</span>
    <button type="button" class="code-copy" aria-label="Copy code">
      <span class="code-copy-icon"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><rect x="9" y="9" width="13" height="13" rx="2" ry="2"/><path d="M5 15H4a2 2 0 01-2-2V4a2 2 0 012-2h9a2 2 0 012 2v1"/></svg>
</span>
      <span class="code-copy-icon code-copy-icon-check"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><polyline points="20 6 9 17 4 12"/></svg>
</span>
      <span class="code-copy-label">Copy</span>
    </button>
  </div>
  <div class="highlight"><pre tabindex="0" class="chroma"><code class="language-shell" data-lang="shell"><span class="line"><span class="cl">❯ terraform apply -target<span class="o">=</span>module.portainer_vm
</span></span><span class="line"><span class="cl">...
</span></span><span class="line"><span class="cl">Apply complete! Resources: <span class="m">2</span> added, <span class="m">0</span> changed, <span class="m">0</span> destroyed.
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl">❯ terraform output portainer_vm_ip
</span></span><span class="line"><span class="cl"><span class="s2">&#34;192.168.1.110&#34;</span></span></span></code></pre></div>
</div>
<p>That <code>-target</code> is required on a from-scratch apply, not optional: the
<code>portainer</code> provider block authenticates against the VM&rsquo;s IP immediately,
before any resource exists, so a plain <code>apply</code> on an empty state fails
before it gets anywhere near creating the VM that IP belongs to.</p>
<h2 id="a-dummy-container-deployed-declaratively">A Dummy Container, Deployed Declaratively</h2>
<p>The issue I wrote for myself said &ldquo;use Portainer to create a dummy
container,&rdquo; which is normally a few clicks - <em>Stacks &gt; Add stack</em>, paste a
compose file, deploy. Since the VM itself is already Terraform, I deployed
the stack the same way instead, through the <code>portainer</code> provider:</p>
<div class="code-block">
  <div class="code-block-header">
    <span class="code-block-lang">hcl</span>
    <button type="button" class="code-copy" aria-label="Copy code">
      <span class="code-copy-icon"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><rect x="9" y="9" width="13" height="13" rx="2" ry="2"/><path d="M5 15H4a2 2 0 01-2-2V4a2 2 0 012-2h9a2 2 0 012 2v1"/></svg>
</span>
      <span class="code-copy-icon code-copy-icon-check"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><polyline points="20 6 9 17 4 12"/></svg>
</span>
      <span class="code-copy-label">Copy</span>
    </button>
  </div>
  <div class="highlight"><pre tabindex="0" class="chroma"><code class="language-hcl" data-lang="hcl"><span class="line"><span class="cl"><span class="k">resource</span> <span class="s2">&#34;portainer_stack&#34; &#34;this&#34;</span> {
</span></span><span class="line"><span class="cl"><span class="n">  name</span>            <span class="o">=</span> <span class="k">var</span><span class="p">.</span><span class="k">name</span>
</span></span><span class="line"><span class="cl"><span class="n">  deployment_type</span> <span class="o">=</span> <span class="s2">&#34;standalone&#34;</span>
</span></span><span class="line"><span class="cl"><span class="n">  method</span>          <span class="o">=</span> <span class="s2">&#34;string&#34;</span>
</span></span><span class="line"><span class="cl"><span class="n">  endpoint_id</span>     <span class="o">=</span> <span class="k">var</span><span class="p">.</span><span class="k">endpoint_id</span>
</span></span><span class="line"><span class="cl"><span class="n">  stack_file_content</span> <span class="o">=</span> <span class="k">var</span><span class="p">.</span><span class="k">compose_content</span>
</span></span><span class="line"><span class="cl"><span class="n">  prune</span> <span class="o">=</span> <span class="kt">true</span>
</span></span><span class="line"><span class="cl">}</span></span></code></pre></div>
</div>
<p>pointed at a genuinely throwaway service:</p>
<div class="code-block">
  <div class="code-block-header">
    <span class="code-block-lang">yaml</span>
    <button type="button" class="code-copy" aria-label="Copy code">
      <span class="code-copy-icon"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><rect x="9" y="9" width="13" height="13" rx="2" ry="2"/><path d="M5 15H4a2 2 0 01-2-2V4a2 2 0 012-2h9a2 2 0 012 2v1"/></svg>
</span>
      <span class="code-copy-icon code-copy-icon-check"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><polyline points="20 6 9 17 4 12"/></svg>
</span>
      <span class="code-copy-label">Copy</span>
    </button>
  </div>
  <div class="highlight"><pre tabindex="0" class="chroma"><code class="language-yaml" data-lang="yaml"><span class="line"><span class="cl"><span class="nt">name</span><span class="p">:</span><span class="w"> </span><span class="l">whoami</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="nt">services</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">  </span><span class="nt">whoami</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">container_name</span><span class="p">:</span><span class="w"> </span><span class="l">whoami</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">image</span><span class="p">:</span><span class="w"> </span><span class="l">traefik/whoami:latest</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">ports</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span>- <span class="s2">&#34;8081:80&#34;</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">restart</span><span class="p">:</span><span class="w"> </span><span class="l">unless-stopped</span></span></span></code></pre></div>
</div>
<div class="code-block">
  <div class="code-block-header">
    <span class="code-block-lang">shell</span>
    <button type="button" class="code-copy" aria-label="Copy code">
      <span class="code-copy-icon"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><rect x="9" y="9" width="13" height="13" rx="2" ry="2"/><path d="M5 15H4a2 2 0 01-2-2V4a2 2 0 012-2h9a2 2 0 012 2v1"/></svg>
</span>
      <span class="code-copy-icon code-copy-icon-check"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><polyline points="20 6 9 17 4 12"/></svg>
</span>
      <span class="code-copy-label">Copy</span>
    </button>
  </div>
  <div class="highlight"><pre tabindex="0" class="chroma"><code class="language-shell" data-lang="shell"><span class="line"><span class="cl">❯ terraform apply
</span></span><span class="line"><span class="cl">...
</span></span><span class="line"><span class="cl">Apply complete! Resources: <span class="m">1</span> added, <span class="m">0</span> changed, <span class="m">0</span> destroyed.
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl">❯ curl -s http://192.168.1.110:8081 <span class="p">|</span> head -3
</span></span><span class="line"><span class="cl">Hostname: a1b2c3d4e5f6
</span></span><span class="line"><span class="cl">IP: 127.0.0.1
</span></span><span class="line"><span class="cl">IP: 172.18.0.2</span></span></code></pre></div>
</div>
<p>The full setup - the VM module, the Portainer stack module, this compose
file - is in <a href="https://github.com/omegion/proxmox-portainer-example" target="_blank" rel="noopener noreferrer"><code>omegion/proxmox-portainer-example</code></a>

on GitHub, sanitized of anything specific to my own network.</p>
<h2 id="conclusion">Conclusion</h2>
<p>One VM, created by Terraform, running Docker and Portainer, with a stack
deployed into it the same declarative way - no manual clicking anywhere in
this loop. It only exists on the LAN right now and there&rsquo;s nothing backing
it up.
<a href="https://omegion.dev/2025/11/proxmox-homelab-backing-up-vms-to-a-nas/">Next post</a>
:
getting it backed up to a NAS.</p>
]]></content:encoded></item><item><title>Restricting Kubernetes Pod Egress with NetworkPolicy</title><link>https://omegion.dev/2025/07/restricting-kubernetes-pod-egress-with-networkpolicy/</link><pubDate>Thu, 24 Jul 2025 00:00:00 +0000</pubDate><guid>https://omegion.dev/2025/07/restricting-kubernetes-pod-egress-with-networkpolicy/</guid><description>Locking a real app down to only the Postgres and Redis it actually needs, with Flannel enforcing the NetworkPolicy on a Talos homelab cluster.</description><content:encoded><![CDATA[<h2 id="introduction">Introduction</h2>
<p>When I was building my Talos Kubernetes homelab cluster, I wrote about
<a href="https://omegion.dev/2023/02/talos-os-setup-a-3-node-homelab-kubernetes-cluster/">setting it up</a>
 and turning on
Flannel&rsquo;s native <code>NetworkPolicy</code> enforcement along the way, with one generic example:
allow HTTPS out, block the home LAN. That was a decent first pass, but it didn&rsquo;t say
anything about what a specific app actually needs. Without a policy, every pod on that
cluster still gets Kubernetes&rsquo; default &ldquo;allow all egress&rdquo;. A pod can reach anything else
in the cluster, or anything on my LAN, just as easily as the one service it&rsquo;s actually
supposed to talk to. This post is the real version of that lock, for one real app:
<a href="https://www.chatwoot.com/" target="_blank" rel="noopener noreferrer">Chatwoot</a>
, which needs the cluster&rsquo;s shared Postgres and its
own Redis, and nothing else.</p>
<h2 id="prerequisites">Prerequisites</h2>
<ol>
<li>A Talos cluster with Flannel&rsquo;s policy enforcement turned on. I covered that when I
first built this cluster, in
<a href="https://omegion.dev/2023/02/talos-os-setup-a-3-node-homelab-kubernetes-cluster/">the post about setting it up</a>
.</li>
<li>An app already running that you want to lock down (Chatwoot, in this example).</li>
<li><code>kubectl</code> pointed at the cluster.</li>
</ol>
<h2 id="what-chatwoot-actually-talks-to">What Chatwoot actually talks to</h2>
<p>Chatwoot&rsquo;s web and worker pods need three things to work: the
<a href="https://omegion.dev/2023/11/talos-os-running-postgres-with-cloudnativepg-on-ceph/">shared CloudNativePG cluster</a>

in the <code>postgres</code> namespace, a Redis instance in Chatwoot&rsquo;s own namespace for the
Sidekiq/Bull job queue and cache, and DNS to resolve both of those. It also needs
outbound SMTP and HTTPS for email and a couple of third-party integrations. Everything
else, the rest of the cluster, my home LAN, the internet at large, is stuff it has no
reason to reach.</p>
<pre class="mermaid" id="mermaid-0"></pre>
<script>(window.__mermaidSources ||= {})["mermaid-0"] = "flowchart TD\n  Pod[\"Chatwoot pod\"] --\u003e|\"5432, allowed\"| PG[\"Postgres (postgres namespace)\"]\n  Pod --\u003e|\"6379, allowed\"| Redis[\"Redis (chatwoot namespace)\"]\n  Pod -.-\u003e|\"6443, denied\"| API[\"kube-apiserver\"]\n  Pod -.-\u003e|\"denied\"| Router[\"Router 192.168.1.1\"]";</script>

<h2 id="the-egress-policy">The egress policy</h2>
<p><code>podSelector: {}</code> matches every pod in the namespace, so the mere presence of this
<code>NetworkPolicy</code> flips Chatwoot from &ldquo;allow all egress&rdquo; to &ldquo;deny anything not explicitly
listed below&rdquo;:</p>
<div class="code-block">
  <div class="code-block-header">
    <span class="code-block-lang">yaml</span>
    <button type="button" class="code-copy" aria-label="Copy code">
      <span class="code-copy-icon"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><rect x="9" y="9" width="13" height="13" rx="2" ry="2"/><path d="M5 15H4a2 2 0 01-2-2V4a2 2 0 012-2h9a2 2 0 012 2v1"/></svg>
</span>
      <span class="code-copy-icon code-copy-icon-check"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><polyline points="20 6 9 17 4 12"/></svg>
</span>
      <span class="code-copy-label">Copy</span>
    </button>
  </div>
  <div class="highlight"><pre tabindex="0" class="chroma"><code class="language-yaml" data-lang="yaml"><span class="line"><span class="cl"><span class="nt">apiVersion</span><span class="p">:</span><span class="w"> </span><span class="l">networking.k8s.io/v1</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="nt">kind</span><span class="p">:</span><span class="w"> </span><span class="l">NetworkPolicy</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="nt">metadata</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">  </span><span class="nt">name</span><span class="p">:</span><span class="w"> </span><span class="l">chatwoot-egress</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="nt">spec</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">  </span><span class="nt">podSelector</span><span class="p">:</span><span class="w"> </span>{}<span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">  </span><span class="nt">policyTypes</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span>- <span class="l">Egress</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">  </span><span class="nt">egress</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="c"># DNS</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span>- <span class="nt">to</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">        </span>- <span class="nt">namespaceSelector</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">            </span><span class="nt">matchLabels</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">              </span><span class="nt">kubernetes.io/metadata.name</span><span class="p">:</span><span class="w"> </span><span class="l">kube-system</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">          </span><span class="nt">podSelector</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">            </span><span class="nt">matchLabels</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">              </span><span class="nt">k8s-app</span><span class="p">:</span><span class="w"> </span><span class="l">kube-dns</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span><span class="nt">ports</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">        </span>- <span class="nt">protocol</span><span class="p">:</span><span class="w"> </span><span class="l">UDP</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">          </span><span class="nt">port</span><span class="p">:</span><span class="w"> </span><span class="m">53</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">        </span>- <span class="nt">protocol</span><span class="p">:</span><span class="w"> </span><span class="l">TCP</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">          </span><span class="nt">port</span><span class="p">:</span><span class="w"> </span><span class="m">53</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="c"># Shared CNPG postgres cluster</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span>- <span class="nt">to</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">        </span>- <span class="nt">namespaceSelector</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">            </span><span class="nt">matchLabels</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">              </span><span class="nt">kubernetes.io/metadata.name</span><span class="p">:</span><span class="w"> </span><span class="l">postgres</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span><span class="nt">ports</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">        </span>- <span class="nt">protocol</span><span class="p">:</span><span class="w"> </span><span class="l">TCP</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">          </span><span class="nt">port</span><span class="p">:</span><span class="w"> </span><span class="m">5432</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="c"># Own redis (Bull/Sidekiq queue + cache), same namespace</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span>- <span class="nt">to</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">        </span>- <span class="nt">podSelector</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">            </span><span class="nt">matchLabels</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">              </span><span class="nt">app.kubernetes.io/name</span><span class="p">:</span><span class="w"> </span><span class="l">chatwoot-redis</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">              </span><span class="nt">app.kubernetes.io/component</span><span class="p">:</span><span class="w"> </span><span class="l">master</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span><span class="nt">ports</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">        </span>- <span class="nt">protocol</span><span class="p">:</span><span class="w"> </span><span class="l">TCP</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">          </span><span class="nt">port</span><span class="p">:</span><span class="w"> </span><span class="m">6379</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="c"># SMTP relay + HTTPS (S3 storage, Slack/Google OAuth). No stable</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="c"># IP/CIDR published for either, so scoped by port only.</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="c"># Still excludes the LAN, just not a specific host.</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span>- <span class="nt">to</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">        </span>- <span class="nt">ipBlock</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">            </span><span class="nt">cidr</span><span class="p">:</span><span class="w"> </span><span class="m">0.0.0.0</span><span class="l">/0</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">            </span><span class="nt">except</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">              </span>- <span class="m">192.168.1.0</span><span class="l">/24</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span><span class="nt">ports</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">        </span>- <span class="nt">protocol</span><span class="p">:</span><span class="w"> </span><span class="l">TCP</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">          </span><span class="nt">port</span><span class="p">:</span><span class="w"> </span><span class="m">587</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">        </span>- <span class="nt">protocol</span><span class="p">:</span><span class="w"> </span><span class="l">TCP</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">          </span><span class="nt">port</span><span class="p">:</span><span class="w"> </span><span class="m">443</span></span></span></code></pre></div>
</div>
<p>No rule for the Kubernetes API, on purpose. Chatwoot doesn&rsquo;t call it, so there&rsquo;s nothing
to allow there.</p>
<h2 id="the-dns-rule-has-to-match-the-pod-not-the-clusterip">The DNS rule has to match the pod, not the ClusterIP</h2>
<p>That first rule looks like it should just be an <code>ipBlock</code> for CoreDNS&rsquo;s ClusterIP. I
wrote it that way the first time, on a different app on this same cluster, and it broke
DNS completely. Flannel&rsquo;s policy engine evaluates <em>after</em> kube-proxy has already rewritten
the destination to CoreDNS&rsquo;s real pod IP, so a rule matching the virtual ClusterIP never
actually matches anything. Matching CoreDNS by <code>namespaceSelector</code> + <code>podSelector</code>
instead, by pod identity rather than by the Service address, is what actually works. I pulled
this into a small shared Helm chart (<code>network-policy-snippets</code>) so every app&rsquo;s egress
policy includes the same correct DNS rule instead of each one risking that mistake on its
own.</p>
<h2 id="testing-it">Testing it</h2>
<p>To check the policy is actually doing what I think it&rsquo;s doing, I dropped a
<a href="https://github.com/nicolaka/netshoot" target="_blank" rel="noopener noreferrer">netshoot</a>
 pod into the <code>chatwoot</code> namespace and
tried reaching both an allowed destination and a denied one:</p>
<div class="code-block">
  <div class="code-block-header">
    <span class="code-block-lang">shell</span>
    <button type="button" class="code-copy" aria-label="Copy code">
      <span class="code-copy-icon"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><rect x="9" y="9" width="13" height="13" rx="2" ry="2"/><path d="M5 15H4a2 2 0 01-2-2V4a2 2 0 012-2h9a2 2 0 012 2v1"/></svg>
</span>
      <span class="code-copy-icon code-copy-icon-check"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><polyline points="20 6 9 17 4 12"/></svg>
</span>
      <span class="code-copy-label">Copy</span>
    </button>
  </div>
  <div class="highlight"><pre tabindex="0" class="chroma"><code class="language-shell" data-lang="shell"><span class="line"><span class="cl">❯ kubectl run chatwoot-netpol-demo -n chatwoot --image<span class="o">=</span>nicolaka/netshoot:latest <span class="se">\
</span></span></span><span class="line"><span class="cl">  --restart<span class="o">=</span>Never --command -- sleep <span class="m">3600</span>
</span></span><span class="line"><span class="cl">pod/chatwoot-netpol-demo created</span></span></code></pre></div>
</div>
<p>Postgres and Redis, both explicitly allowed, connect straight away:</p>
<div class="code-block">
  <div class="code-block-header">
    <span class="code-block-lang">shell</span>
    <button type="button" class="code-copy" aria-label="Copy code">
      <span class="code-copy-icon"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><rect x="9" y="9" width="13" height="13" rx="2" ry="2"/><path d="M5 15H4a2 2 0 01-2-2V4a2 2 0 012-2h9a2 2 0 012 2v1"/></svg>
</span>
      <span class="code-copy-icon code-copy-icon-check"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><polyline points="20 6 9 17 4 12"/></svg>
</span>
      <span class="code-copy-label">Copy</span>
    </button>
  </div>
  <div class="highlight"><pre tabindex="0" class="chroma"><code class="language-shell" data-lang="shell"><span class="line"><span class="cl">❯ kubectl <span class="nb">exec</span> -n chatwoot chatwoot-netpol-demo -- <span class="se">\
</span></span></span><span class="line"><span class="cl">  nc -zv -w3 postgres.postgres.svc.cluster.local <span class="m">5432</span>
</span></span><span class="line"><span class="cl">Connection to postgres.postgres.svc.cluster.local <span class="o">(</span>10.107.254.52<span class="o">)</span> <span class="m">5432</span> port <span class="o">[</span>tcp/postgresql<span class="o">]</span> succeeded!
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl">❯ kubectl <span class="nb">exec</span> -n chatwoot chatwoot-netpol-demo -- <span class="se">\
</span></span></span><span class="line"><span class="cl">  nc -zv -w3 chatwoot-chatwoot-redis-master <span class="m">6379</span>
</span></span><span class="line"><span class="cl">Connection to chatwoot-chatwoot-redis-master <span class="o">(</span>10.104.168.82<span class="o">)</span> <span class="m">6379</span> port <span class="o">[</span>tcp/redis<span class="o">]</span> succeeded!</span></span></code></pre></div>
</div>
<p>The kube-apiserver, which isn&rsquo;t in the policy at all, just hangs until the connection
times out:</p>
<div class="code-block">
  <div class="code-block-header">
    <span class="code-block-lang">shell</span>
    <button type="button" class="code-copy" aria-label="Copy code">
      <span class="code-copy-icon"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><rect x="9" y="9" width="13" height="13" rx="2" ry="2"/><path d="M5 15H4a2 2 0 01-2-2V4a2 2 0 012-2h9a2 2 0 012 2v1"/></svg>
</span>
      <span class="code-copy-icon code-copy-icon-check"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><polyline points="20 6 9 17 4 12"/></svg>
</span>
      <span class="code-copy-label">Copy</span>
    </button>
  </div>
  <div class="highlight"><pre tabindex="0" class="chroma"><code class="language-shell" data-lang="shell"><span class="line"><span class="cl">❯ kubectl <span class="nb">exec</span> -n chatwoot chatwoot-netpol-demo -- nc -zv -w5 192.168.1.10 <span class="m">6443</span>
</span></span><span class="line"><span class="cl">nc: connect to 192.168.1.10 port <span class="m">6443</span> <span class="o">(</span>tcp<span class="o">)</span> timed out: Operation in progress
</span></span><span class="line"><span class="cl"><span class="nb">command</span> terminated with <span class="nb">exit</span> code <span class="m">1</span></span></span></code></pre></div>
</div>
<p>That&rsquo;s the difference between a policy that exists and a policy that&rsquo;s actually
enforced. I deleted the pod once I&rsquo;d seen both results.</p>
<h2 id="conclusion">Conclusion</h2>
<p>It&rsquo;s still not real network isolation, though. Enforcement happens in Flannel, on the
same node the pod runs on, not on a VLAN or a separate firewall device sitting between
the cluster and the rest of the LAN. A CNI bug or a misconfigured node bypasses it
entirely.
What it does catch is the boring failure, an app reaching somewhere it has no business
reaching, and that&rsquo;s the case that actually happens on a homelab. Anywhere the stakes
were higher, physical or VLAN segmentation would still be the real fix, not a
<code>NetworkPolicy</code>.</p>
]]></content:encoded></item><item><title>Usertune</title><link>https://omegion.dev/projects/usertune/</link><pubDate>Wed, 09 Jul 2025 00:00:00 +0000</pubDate><guid>https://omegion.dev/projects/usertune/</guid><description>Real-time content personalization API based on location, device, and behavior.</description><content:encoded><![CDATA[<p>I built Usertune because I couldn&rsquo;t find a content personalization service
that was developer-friendly and not built for enterprise budgets. Most
personalization platforms assume a marketing team and a big contract —
I wanted something you could wire up with a REST call and iterate on
yourself.</p>
<p>Usertune served personalized content in real time based on user location,
device, behavior, and custom attributes, over a REST API, JS SDK, or
GraphQL endpoint, with webhooks for event-driven cases. Content was
delivered from the edge to keep latency down, and the dashboard covered
the basics: conversion rates, engagement, and A/B testing content
variants.</p>
]]></content:encoded></item><item><title>Talos OS: Securing an Exposed App with Cloudflare Zero Trust</title><link>https://omegion.dev/2025/06/talos-os-securing-an-exposed-app-with-cloudflare-zero-trust/</link><pubDate>Thu, 05 Jun 2025 00:00:00 +0000</pubDate><guid>https://omegion.dev/2025/06/talos-os-securing-an-exposed-app-with-cloudflare-zero-trust/</guid><description>Putting Cloudflare Access in front of a homelab app exposed through cloudflared, with Okta as the identity provider and a service token for API access.</description><content:encoded><![CDATA[<h2 id="introduction">Introduction</h2>
<p>In the <a href="https://omegion.dev/2024/09/talos-os-exposing-a-homelab-service-with-cloudflare-tunnel/">previous post</a>

I got an app on my Talos cluster reachable from the internet through
<code>cloudflared</code>. That was also the problem it left behind: anyone with the
URL could hit it, no login, nothing. Before I put anything real behind a
tunnel, it needs to actually require someone to be who they say they are.
That&rsquo;s Cloudflare Access, and this post is wiring it up.</p>
<h2 id="why-zero-trust-instead-of-auth-in-the-app">Why Zero Trust Instead of Auth in the App</h2>
<p>The easy option would be adding a login page to the app itself. Cloudflare
Access does the check in front of the tunnel instead, before the request
ever reaches the cluster - the app doesn&rsquo;t need to know anything about
authentication at all. The &ldquo;zero trust&rdquo; part is the model behind it: access
is decided by who you are, not by whether you&rsquo;re on my LAN or a VPN.
Nothing gets a pass just because of where the request came from.</p>
<pre class="mermaid" id="mermaid-0"></pre>
<script>(window.__mermaidSources ||= {})["mermaid-0"] = "flowchart TD\n  Browser[\"Browser\"] --\u003e Access[\"Cloudflare Access\"]\n  Script[\"curl + service token\"] --\u003e Access\n  Access --\u003e|\"Okta login\"| App[\"nginx pod\"]\n  Access --\u003e|\"valid token\"| App";</script>

<h2 id="choosing-an-identity-provider">Choosing an Identity Provider</h2>
<p>Cloudflare Access supports a handful of identity providers - Google,
GitHub, one-time PIN over email, generic OIDC/SAML. I picked Okta: simpler
to wire up as a single IdP than standing up something else just for a
one-person homelab, and it&rsquo;s the one I&rsquo;m managing the policy against for
every app that needs it.</p>
<h2 id="terraform-the-access-application">Terraform: The Access Application</h2>
<p>Each protected hostname gets a <code>cloudflare_access_application</code> plus a
policy that only lets a specific Okta group through:</p>
<div class="code-block">
  <div class="code-block-header">
    <span class="code-block-lang">hcl</span>
    <button type="button" class="code-copy" aria-label="Copy code">
      <span class="code-copy-icon"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><rect x="9" y="9" width="13" height="13" rx="2" ry="2"/><path d="M5 15H4a2 2 0 01-2-2V4a2 2 0 012-2h9a2 2 0 012 2v1"/></svg>
</span>
      <span class="code-copy-icon code-copy-icon-check"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><polyline points="20 6 9 17 4 12"/></svg>
</span>
      <span class="code-copy-label">Copy</span>
    </button>
  </div>
  <div class="highlight"><pre tabindex="0" class="chroma"><code class="language-hcl" data-lang="hcl"><span class="line"><span class="cl"><span class="k">resource</span> <span class="s2">&#34;cloudflare_access_application&#34; &#34;this&#34;</span> {
</span></span><span class="line"><span class="cl"><span class="n">  zone_id</span>                   <span class="o">=</span> <span class="k">data</span><span class="p">.</span><span class="k">cloudflare_zones</span><span class="p">.</span><span class="k">this</span><span class="p">.</span><span class="k">zones</span><span class="p">[</span><span class="m">0</span><span class="p">].</span><span class="k">id</span>
</span></span><span class="line"><span class="cl"><span class="n">  name</span>                      <span class="o">=</span> <span class="k">var</span><span class="p">.</span><span class="k">hostname</span>
</span></span><span class="line"><span class="cl"><span class="n">  domain</span>                    <span class="o">=</span> <span class="s2">&#34;${var.hostname}.${var.domain}&#34;</span>
</span></span><span class="line"><span class="cl"><span class="n">  allowed_idps</span>              <span class="o">=</span> <span class="p">[</span><span class="k">data</span><span class="p">.</span><span class="k">cloudflare_access_identity_provider</span><span class="p">.</span><span class="k">okta</span><span class="p">.</span><span class="k">id</span><span class="p">]</span>
</span></span><span class="line"><span class="cl"><span class="n">  type</span>                      <span class="o">=</span> <span class="s2">&#34;self_hosted&#34;</span>
</span></span><span class="line"><span class="cl"><span class="n">  session_duration</span>          <span class="o">=</span> <span class="s2">&#34;730h&#34;</span><span class="c1">   # ~30 days
</span></span></span><span class="line"><span class="cl"><span class="n">  auto_redirect_to_identity</span> <span class="o">=</span> <span class="kt">true</span>
</span></span><span class="line"><span class="cl">}
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="k">resource</span> <span class="s2">&#34;cloudflare_access_policy&#34; &#34;this&#34;</span> {
</span></span><span class="line"><span class="cl"><span class="n">  application_id</span> <span class="o">=</span> <span class="k">cloudflare_access_application</span><span class="p">.</span><span class="k">this</span><span class="p">.</span><span class="k">id</span>
</span></span><span class="line"><span class="cl"><span class="n">  zone_id</span>        <span class="o">=</span> <span class="k">data</span><span class="p">.</span><span class="k">cloudflare_zones</span><span class="p">.</span><span class="k">this</span><span class="p">.</span><span class="k">zones</span><span class="p">[</span><span class="m">0</span><span class="p">].</span><span class="k">id</span>
</span></span><span class="line"><span class="cl"><span class="n">  name</span>           <span class="o">=</span> <span class="s2">&#34;Okta Groups&#34;</span>
</span></span><span class="line"><span class="cl"><span class="n">  precedence</span>     <span class="o">=</span> <span class="s2">&#34;1&#34;</span>
</span></span><span class="line"><span class="cl"><span class="n">  decision</span>       <span class="o">=</span> <span class="s2">&#34;allow&#34;</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl">  <span class="k">include</span> {
</span></span><span class="line"><span class="cl">    <span class="k">okta</span> {
</span></span><span class="line"><span class="cl"><span class="n">      name</span>                 <span class="o">=</span> <span class="k">var</span><span class="p">.</span><span class="k">okta_groups</span>
</span></span><span class="line"><span class="cl"><span class="n">      identity_provider_id</span> <span class="o">=</span> <span class="k">data</span><span class="p">.</span><span class="k">cloudflare_access_identity_provider</span><span class="p">.</span><span class="k">okta</span><span class="p">.</span><span class="k">id</span>
</span></span><span class="line"><span class="cl">    }
</span></span><span class="line"><span class="cl">  }
</span></span><span class="line"><span class="cl">}</span></span></code></pre></div>
</div>
<p><code>auto_redirect_to_identity</code> skips Cloudflare&rsquo;s own IdP picker and sends
visitors straight to Okta. <code>session_duration</code> controls how long that login
sticks before Access makes them go through Okta again - long enough that I
don&rsquo;t re-authenticate on every visit, short enough that it isn&rsquo;t
effectively permanent.</p>
<div class="code-block">
  <div class="code-block-header">
    <span class="code-block-lang">shell</span>
    <button type="button" class="code-copy" aria-label="Copy code">
      <span class="code-copy-icon"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><rect x="9" y="9" width="13" height="13" rx="2" ry="2"/><path d="M5 15H4a2 2 0 01-2-2V4a2 2 0 012-2h9a2 2 0 012 2v1"/></svg>
</span>
      <span class="code-copy-icon code-copy-icon-check"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><polyline points="20 6 9 17 4 12"/></svg>
</span>
      <span class="code-copy-label">Copy</span>
    </button>
  </div>
  <div class="highlight"><pre tabindex="0" class="chroma"><code class="language-shell" data-lang="shell"><span class="line"><span class="cl">❯ terraform apply
</span></span><span class="line"><span class="cl">...
</span></span><span class="line"><span class="cl">  <span class="c1"># cloudflare_access_application.this will be created</span>
</span></span><span class="line"><span class="cl">  <span class="c1"># cloudflare_access_policy.this will be created</span>
</span></span><span class="line"><span class="cl">Plan: <span class="m">2</span> to add, <span class="m">0</span> to change, <span class="m">0</span> to destroy.
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl">cloudflare_access_application.this: Creating...
</span></span><span class="line"><span class="cl">cloudflare_access_policy.this: Creating...
</span></span><span class="line"><span class="cl">Apply complete! Resources: <span class="m">2</span> added, <span class="m">0</span> changed, <span class="m">0</span> destroyed.</span></span></code></pre></div>
</div>
<p>That&rsquo;s what shows up in the dashboard afterward - the Destinations tab on
one of my actual protected apps (not <code>nginx-demo</code>, but the same shape:
one public hostname per application):</p>
<p><img src="/2025/06/talos-os-securing-an-exposed-app-with-cloudflare-zero-trust/images/access-application-destinations.png" alt="Access Application destinations tab, showing a public hostname configured for a self-hosted app" title="Application details - Destinations"></p>
<p>And the Access policy it creates - on this app the policy happens to be
named &ldquo;Okta Admin&rdquo; rather than &ldquo;Okta Groups&rdquo;, but it&rsquo;s the same
default-deny-plus-an-Okta-group pattern from the Terraform above:</p>
<p><img src="/2025/06/talos-os-securing-an-exposed-app-with-cloudflare-zero-trust/images/access-policies-list.png" alt="Access policies list showing an Okta-backed allow policy" title="Access policies"></p>
<p>Hitting <code>nginx.example.com</code> in a browser now bounces through Okta before
it ever reaches nginx, and only members of the group in <code>okta_groups</code> get
past the login.</p>
<h2 id="what-about-scripts-and-curl">What About Scripts and curl?</h2>
<p>An Okta redirect is fine for a browser, useless for anything scripted -
<code>curl</code> can&rsquo;t click through a login page. Cloudflare Access covers that with
service tokens: a separate policy, opt-in per application, that doesn&rsquo;t
touch Okta at all:</p>
<div class="code-block">
  <div class="code-block-header">
    <span class="code-block-lang">hcl</span>
    <button type="button" class="code-copy" aria-label="Copy code">
      <span class="code-copy-icon"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><rect x="9" y="9" width="13" height="13" rx="2" ry="2"/><path d="M5 15H4a2 2 0 01-2-2V4a2 2 0 012-2h9a2 2 0 012 2v1"/></svg>
</span>
      <span class="code-copy-icon code-copy-icon-check"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><polyline points="20 6 9 17 4 12"/></svg>
</span>
      <span class="code-copy-label">Copy</span>
    </button>
  </div>
  <div class="highlight"><pre tabindex="0" class="chroma"><code class="language-hcl" data-lang="hcl"><span class="line"><span class="cl"><span class="k">resource</span> <span class="s2">&#34;cloudflare_access_policy&#34; &#34;service_token&#34;</span> {
</span></span><span class="line"><span class="cl"><span class="n">  count</span> <span class="o">=</span> <span class="k">var</span><span class="p">.</span><span class="k">enable_service_token</span> <span class="err">?</span> <span class="m">1</span> <span class="err">:</span> <span class="m">0</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="n">  application_id</span> <span class="o">=</span> <span class="k">cloudflare_access_application</span><span class="p">.</span><span class="k">this</span><span class="p">.</span><span class="k">id</span>
</span></span><span class="line"><span class="cl"><span class="n">  zone_id</span>        <span class="o">=</span> <span class="k">data</span><span class="p">.</span><span class="k">cloudflare_zones</span><span class="p">.</span><span class="k">this</span><span class="p">.</span><span class="k">zones</span><span class="p">[</span><span class="m">0</span><span class="p">].</span><span class="k">id</span>
</span></span><span class="line"><span class="cl"><span class="n">  name</span>           <span class="o">=</span> <span class="s2">&#34;Service Token&#34;</span>
</span></span><span class="line"><span class="cl"><span class="n">  precedence</span>     <span class="o">=</span> <span class="s2">&#34;3&#34;</span>
</span></span><span class="line"><span class="cl"><span class="n">  decision</span>       <span class="o">=</span> <span class="s2">&#34;non_identity&#34;</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl">  <span class="k">include</span> {
</span></span><span class="line"><span class="cl"><span class="n">    any_valid_service_token</span> <span class="o">=</span> <span class="kt">true</span>
</span></span><span class="line"><span class="cl">  }
</span></span><span class="line"><span class="cl">}</span></span></code></pre></div>
</div>
<p>With that policy in place, a request carrying a valid service token&rsquo;s
client ID and secret gets through without ever seeing the Okta redirect:</p>
<div class="code-block">
  <div class="code-block-header">
    <span class="code-block-lang">shell</span>
    <button type="button" class="code-copy" aria-label="Copy code">
      <span class="code-copy-icon"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><rect x="9" y="9" width="13" height="13" rx="2" ry="2"/><path d="M5 15H4a2 2 0 01-2-2V4a2 2 0 012-2h9a2 2 0 012 2v1"/></svg>
</span>
      <span class="code-copy-icon code-copy-icon-check"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><polyline points="20 6 9 17 4 12"/></svg>
</span>
      <span class="code-copy-label">Copy</span>
    </button>
  </div>
  <div class="highlight"><pre tabindex="0" class="chroma"><code class="language-shell" data-lang="shell"><span class="line"><span class="cl">❯ curl -H <span class="s2">&#34;CF-Access-Client-Id: &lt;client-id&gt;.access&#34;</span> <span class="se">\
</span></span></span><span class="line"><span class="cl">  -H <span class="s2">&#34;CF-Access-Client-Secret: &lt;client-secret&gt;&#34;</span> <span class="se">\
</span></span></span><span class="line"><span class="cl">  https://nginx.example.com</span></span></code></pre></div>
</div>
<p>It&rsquo;s a separate credential from anyone&rsquo;s Okta login, so I mint one per
script or automation that needs in, and it can be revoked on its own
without touching anyone&rsquo;s actual account.</p>
<h2 id="conclusion">Conclusion</h2>
<p>The cluster from the first post, storage from the second, a database in
the third, and now anything exposed through the fourth post&rsquo;s tunnel
requires either an Okta login or a service token to reach it at all - no
more anonymous public access to anything sitting behind Cloudflare on this
homelab. Four posts to get here, and the three mini PCs that used to just
sit on my desk now run a cluster where nothing gets in without proving
who it is.</p>
]]></content:encoded></item><item><title>Cronjobly</title><link>https://omegion.dev/projects/cronjobly/</link><pubDate>Sat, 01 Feb 2025 00:00:00 +0000</pubDate><guid>https://omegion.dev/projects/cronjobly/</guid><description>Schedule, monitor, and manage cron jobs, with real-time logs and failure alerts.</description><content:encoded>&lt;p>Cronjobly scheduled, ran, and monitored cron jobs written in JavaScript,
Python, or Go, on serverless infrastructure — no servers to maintain.
Standard cron expressions, a RESTful API, environment variable management,
and a dashboard with per-job execution history for debugging failures.&lt;/p>
</content:encoded></item><item><title>Talos OS: Exposing a Homelab Service with Cloudflare Tunnel</title><link>https://omegion.dev/2024/09/talos-os-exposing-a-homelab-service-with-cloudflare-tunnel/</link><pubDate>Thu, 19 Sep 2024 00:00:00 +0000</pubDate><guid>https://omegion.dev/2024/09/talos-os-exposing-a-homelab-service-with-cloudflare-tunnel/</guid><description>Exposing an app running on a Talos homelab cluster to the internet with cloudflared, without opening a port on the home router.</description><content:encoded><![CDATA[<h2 id="introduction">Introduction</h2>
<p>Between the <a href="https://omegion.dev/2023/02/talos-os-setup-a-3-node-homelab-kubernetes-cluster/">cluster</a>
, the
<a href="https://omegion.dev/2023/03/talos-os-persistent-volumes-with-rook-ceph/">storage</a>
,
and <a href="https://omegion.dev/2023/11/talos-os-running-postgres-with-cloudnativepg-on-ceph/">Postgres</a>
,
I&rsquo;ve got a real place to run apps now. All of that is still only reachable
from inside my LAN, though. This post is getting a service out to the
internet without opening a port on my home router or publishing my home IP
anywhere - <code>cloudflared</code> running as an ingress controller inside the
cluster.</p>
<h2 id="why-a-tunnel-instead-of-port-forwarding">Why a Tunnel Instead of Port Forwarding</h2>
<p><code>cloudflared</code> makes an outbound connection from inside the cluster out to
Cloudflare&rsquo;s edge and keeps it open. Traffic comes in over that connection,
not through an inbound port on my router. There&rsquo;s nothing to forward, no
firewall rule pointing at my home IP, and no home IP exposed in DNS at all - every request goes through Cloudflare&rsquo;s edge first.</p>
<p>Routing everything through that edge also means bot protection, WAF rules,
and rate limiting are available on the same traffic, configured in
Cloudflare rather than in the app or on the tunnel itself - nginx here
doesn&rsquo;t have to know any of that exists. I haven&rsquo;t turned any of it on yet
for this dummy app, but it&rsquo;s sitting there for when something behind this
tunnel actually needs it.</p>
<pre class="mermaid" id="mermaid-0"></pre>
<script>(window.__mermaidSources ||= {})["mermaid-0"] = "flowchart TD\n  User[\"Browser\"] --\u003e CF[\"Cloudflare edge\"]\n  CF -.-\u003e|\"outbound tunnel\"| CFD[\"cloudflared pod\"]\n  CFD --\u003e App[\"nginx pod\"]";</script>

<h2 id="prerequisites">Prerequisites</h2>
<ol>
<li>The Talos cluster from the
<a href="https://omegion.dev/2023/02/talos-os-setup-a-3-node-homelab-kubernetes-cluster/">first post</a>
 and the
<code>ceph-rbd</code> storage class from the
<a href="https://omegion.dev/2023/03/talos-os-persistent-volumes-with-rook-ceph/">second post</a>
.</li>
<li>A domain
<a href="https://developers.cloudflare.com/fundamentals/manage-domains/add-site/" target="_blank" rel="noopener noreferrer">onboarded to a Cloudflare account</a>
.</li>
<li><a href="https://helm.sh/docs/intro/install/" target="_blank" rel="noopener noreferrer">Helm 3</a>
.</li>
<li>A Cloudflare API token
<a href="https://developers.cloudflare.com/fundamentals/api/get-started/create-token/" target="_blank" rel="noopener noreferrer">scoped to manage Tunnels</a>
.</li>
<li>The <a href="https://github.com/STRRL/cloudflare-tunnel-ingress-controller" target="_blank" rel="noopener noreferrer"><code>cloudflare-tunnel-ingress-controller</code></a>

Helm chart.</li>
</ol>
<h2 id="installing-the-tunnel-ingress-controller">Installing the Tunnel Ingress Controller</h2>
<p>I run the <code>cloudflare-tunnel-ingress-controller</code> chart, which both manages
the <code>cloudflared</code> pods and watches Kubernetes <code>Ingress</code> objects to create
the matching tunnel routes and DNS records automatically:</p>
<div class="code-block">
  <div class="code-block-header">
    <span class="code-block-lang">yaml</span>
    <button type="button" class="code-copy" aria-label="Copy code">
      <span class="code-copy-icon"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><rect x="9" y="9" width="13" height="13" rx="2" ry="2"/><path d="M5 15H4a2 2 0 01-2-2V4a2 2 0 012-2h9a2 2 0 012 2v1"/></svg>
</span>
      <span class="code-copy-icon code-copy-icon-check"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><polyline points="20 6 9 17 4 12"/></svg>
</span>
      <span class="code-copy-label">Copy</span>
    </button>
  </div>
  <div class="highlight"><pre tabindex="0" class="chroma"><code class="language-yaml" data-lang="yaml"><span class="line"><span class="cl"><span class="nt">cloudflare-tunnel-ingress-controller</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">  </span><span class="nt">cloudflare</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">accountId</span><span class="p">:</span><span class="w"> </span><span class="s2">&#34;&lt;account-id&gt;&#34;</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">tunnelName</span><span class="p">:</span><span class="w"> </span><span class="s2">&#34;homelab-ingress&#34;</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">apiToken</span><span class="p">:</span><span class="w"> </span><span class="s2">&#34;&lt;api-token&gt;&#34;</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">  </span><span class="nt">cloudflared</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">replicaCount</span><span class="p">:</span><span class="w"> </span><span class="m">2</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">podAntiAffinity</span><span class="p">:</span><span class="w"> </span><span class="kc">true</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">resources</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span><span class="nt">requests</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">        </span><span class="nt">cpu</span><span class="p">:</span><span class="w"> </span><span class="l">50m</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">        </span><span class="nt">memory</span><span class="p">:</span><span class="w"> </span><span class="l">64Mi</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span><span class="nt">limits</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">        </span><span class="nt">cpu</span><span class="p">:</span><span class="w"> </span><span class="l">250m</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">        </span><span class="nt">memory</span><span class="p">:</span><span class="w"> </span><span class="l">256Mi</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">pdb</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span><span class="nt">enabled</span><span class="p">:</span><span class="w"> </span><span class="kc">true</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span><span class="nt">minAvailable</span><span class="p">:</span><span class="w"> </span><span class="m">1</span></span></span></code></pre></div>
</div>
<div class="code-block">
  <div class="code-block-header">
    <span class="code-block-lang">shell</span>
    <button type="button" class="code-copy" aria-label="Copy code">
      <span class="code-copy-icon"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><rect x="9" y="9" width="13" height="13" rx="2" ry="2"/><path d="M5 15H4a2 2 0 01-2-2V4a2 2 0 012-2h9a2 2 0 012 2v1"/></svg>
</span>
      <span class="code-copy-icon code-copy-icon-check"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><polyline points="20 6 9 17 4 12"/></svg>
</span>
      <span class="code-copy-label">Copy</span>
    </button>
  </div>
  <div class="highlight"><pre tabindex="0" class="chroma"><code class="language-shell" data-lang="shell"><span class="line"><span class="cl">❯ helm install cloudflare-ingress cloudflare/cloudflare-tunnel-ingress-controller <span class="se">\
</span></span></span><span class="line"><span class="cl">  -n cloudflare-ingress --create-namespace -f values.yaml</span></span></code></pre></div>
</div>
<p>I didn&rsquo;t start with the <code>replicaCount</code>, <code>podAntiAffinity</code>, and <code>resources</code>
block above - the chart defaults to a single replica with no resource
limits at all, which meant every app behind the tunnel went down with that
one pod. I also caught it stuck on a stale image build: <code>cloudflared</code> warns
&ldquo;outdated&rdquo; on every startup, and left on <code>latest</code> with <code>IfNotPresent</code> it
just never re-pulls once a pod already exists. Pinning the tag and running
two replicas spread across nodes with a <code>PodDisruptionBudget</code> fixed both.</p>
<h2 id="pointing-an-app-at-it">Pointing an App at It</h2>
<p>Any <code>Ingress</code> with <code>ingressClassName: cloudflare-tunnel</code> gets picked up by
the controller automatically - no manual <code>cloudflared tunnel route dns</code>
step. To prove it works, a dummy nginx deployment:</p>
<div class="code-block">
  <div class="code-block-header">
    <span class="code-block-lang">yaml</span>
    <button type="button" class="code-copy" aria-label="Copy code">
      <span class="code-copy-icon"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><rect x="9" y="9" width="13" height="13" rx="2" ry="2"/><path d="M5 15H4a2 2 0 01-2-2V4a2 2 0 012-2h9a2 2 0 012 2v1"/></svg>
</span>
      <span class="code-copy-icon code-copy-icon-check"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><polyline points="20 6 9 17 4 12"/></svg>
</span>
      <span class="code-copy-label">Copy</span>
    </button>
  </div>
  <div class="highlight"><pre tabindex="0" class="chroma"><code class="language-yaml" data-lang="yaml"><span class="line"><span class="cl"><span class="nt">apiVersion</span><span class="p">:</span><span class="w"> </span><span class="l">apps/v1</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="nt">kind</span><span class="p">:</span><span class="w"> </span><span class="l">Deployment</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="nt">metadata</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">  </span><span class="nt">name</span><span class="p">:</span><span class="w"> </span><span class="l">nginx-demo</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="nt">spec</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">  </span><span class="nt">replicas</span><span class="p">:</span><span class="w"> </span><span class="m">1</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">  </span><span class="nt">selector</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">matchLabels</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span><span class="nt">app</span><span class="p">:</span><span class="w"> </span><span class="l">nginx-demo</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">  </span><span class="nt">template</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">metadata</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span><span class="nt">labels</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">        </span><span class="nt">app</span><span class="p">:</span><span class="w"> </span><span class="l">nginx-demo</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">spec</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span><span class="nt">containers</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">        </span>- <span class="nt">name</span><span class="p">:</span><span class="w"> </span><span class="l">nginx</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">          </span><span class="nt">image</span><span class="p">:</span><span class="w"> </span><span class="l">nginx:1.27</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">          </span><span class="nt">ports</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">            </span>- <span class="nt">containerPort</span><span class="p">:</span><span class="w"> </span><span class="m">80</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="nn">---</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="nt">apiVersion</span><span class="p">:</span><span class="w"> </span><span class="l">v1</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="nt">kind</span><span class="p">:</span><span class="w"> </span><span class="l">Service</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="nt">metadata</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">  </span><span class="nt">name</span><span class="p">:</span><span class="w"> </span><span class="l">nginx-demo</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="nt">spec</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">  </span><span class="nt">selector</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">app</span><span class="p">:</span><span class="w"> </span><span class="l">nginx-demo</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">  </span><span class="nt">ports</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span>- <span class="nt">port</span><span class="p">:</span><span class="w"> </span><span class="m">80</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span><span class="nt">targetPort</span><span class="p">:</span><span class="w"> </span><span class="m">80</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="nn">---</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="nt">apiVersion</span><span class="p">:</span><span class="w"> </span><span class="l">networking.k8s.io/v1</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="nt">kind</span><span class="p">:</span><span class="w"> </span><span class="l">Ingress</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="nt">metadata</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">  </span><span class="nt">name</span><span class="p">:</span><span class="w"> </span><span class="l">nginx-demo</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="nt">spec</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">  </span><span class="nt">ingressClassName</span><span class="p">:</span><span class="w"> </span><span class="l">cloudflare-tunnel</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">  </span><span class="nt">rules</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span>- <span class="nt">host</span><span class="p">:</span><span class="w"> </span><span class="l">nginx.example.com</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span><span class="nt">http</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">        </span><span class="nt">paths</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">          </span>- <span class="nt">path</span><span class="p">:</span><span class="w"> </span><span class="l">/</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">            </span><span class="nt">pathType</span><span class="p">:</span><span class="w"> </span><span class="l">Prefix</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">            </span><span class="nt">backend</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">              </span><span class="nt">service</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">                </span><span class="nt">name</span><span class="p">:</span><span class="w"> </span><span class="l">nginx-demo</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">                </span><span class="nt">port</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">                  </span><span class="nt">number</span><span class="p">:</span><span class="w"> </span><span class="m">80</span></span></span></code></pre></div>
</div>
<div class="code-block">
  <div class="code-block-header">
    <span class="code-block-lang">shell</span>
    <button type="button" class="code-copy" aria-label="Copy code">
      <span class="code-copy-icon"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><rect x="9" y="9" width="13" height="13" rx="2" ry="2"/><path d="M5 15H4a2 2 0 01-2-2V4a2 2 0 012-2h9a2 2 0 012 2v1"/></svg>
</span>
      <span class="code-copy-icon code-copy-icon-check"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><polyline points="20 6 9 17 4 12"/></svg>
</span>
      <span class="code-copy-label">Copy</span>
    </button>
  </div>
  <div class="highlight"><pre tabindex="0" class="chroma"><code class="language-shell" data-lang="shell"><span class="line"><span class="cl">❯ kubectl apply -f nginx-demo.yaml
</span></span><span class="line"><span class="cl">❯ curl -sI https://nginx.example.com
</span></span><span class="line"><span class="cl">HTTP/2 <span class="m">200</span>
</span></span><span class="line"><span class="cl">server: cloudflare
</span></span><span class="line"><span class="cl">content-type: text/html</span></span></code></pre></div>
</div>
<p>That request went from my laptop, out to Cloudflare, back down through the
tunnel, into the cluster, and hit nginx - without touching my router at
all:</p>
<pre class="mermaid" id="mermaid-5"></pre>
<script>(window.__mermaidSources ||= {})["mermaid-5"] = "flowchart TD\n  Laptop([\"My laptop\"])\n\n  subgraph Internet[\"Internet\"]\n    Edge[\"Cloudflare edge\"]\n  end\n\n  subgraph Home[\"My home network\"]\n    Tunnel[\"cloudflared tunnel\"]\n    Nginx[\"nginx pod\"]\n  end\n\n  Laptop --\u003e Edge\n  Edge -.-\u003e|\"outbound tunnel\"| Tunnel\n  Tunnel --\u003e Nginx";</script>

<h2 id="conclusion">Conclusion</h2>
<p>The app is reachable from the internet now, which is the point, but also
the problem: right now anyone with the URL can hit it, nginx welcome page
and all.
<a href="https://omegion.dev/2025/06/talos-os-securing-an-exposed-app-with-cloudflare-zero-trust/">Next post</a>
:
putting Cloudflare Zero Trust in front of this so it actually requires a
login.</p>
]]></content:encoded></item><item><title>How to Host a Personal Blog with Hugo on Cloudflare Pages</title><link>https://omegion.dev/2024/03/how-to-host-a-personal-blog-with-hugo-on-cloudflare-pages/</link><pubDate>Mon, 04 Mar 2024 00:00:00 +0000</pubDate><guid>https://omegion.dev/2024/03/how-to-host-a-personal-blog-with-hugo-on-cloudflare-pages/</guid><description>The same Hugo site from the GitHub Pages post, this time deployed to Cloudflare Pages with direct upload and Wrangler instead of GitHub&amp;rsquo;s own Pages pipeline.</description><content:encoded><![CDATA[<h2 id="introduction">Introduction</h2>
<p><a href="https://omegion.dev/2024/02/how-to-host-a-personal-blog-with-hugo-on-github-pages/">How to Host a Personal Blog with Hugo on GitHub Pages</a>

walked through getting a Hugo site onto GitHub Pages, a GitHub Actions
workflow that builds the site and lets GitHub serve the result for free.
Cloudflare Pages does the same job, hosting a folder of
static files, but the path to get there is different. Instead of a
Git-connected build, you push an already-built directory straight up with
<a href="https://developers.cloudflare.com/workers/wrangler/" target="_blank" rel="noopener noreferrer">Wrangler</a>
, Cloudflare&rsquo;s
CLI, from your own CI. Same Hugo site, same <code>public/</code> output, a different
way of shipping it.</p>
<h2 id="prerequisites">Prerequisites</h2>
<ol>
<li><a href="https://gohugo.io/installation/" target="_blank" rel="noopener noreferrer">Hugo Extended</a>
, <code>0.146.0</code> or newer.</li>
<li><a href="https://nodejs.org/" target="_blank" rel="noopener noreferrer">Node.js</a>
 and npm, to run Wrangler.</li>
<li>A <a href="https://dash.cloudflare.com/sign-up" target="_blank" rel="noopener noreferrer">Cloudflare</a>
 account.</li>
<li>A Hugo site. If you don&rsquo;t have one yet, check the
<a href="https://omegion.dev/2024/02/how-to-host-a-personal-blog-with-hugo-on-github-pages/">previous post</a>

for that from an empty folder. A GitHub repo for it only matters later,
for the GitHub Actions section.</li>
</ol>
<h2 id="create-a-cloudflare-pages-project">Create a Cloudflare Pages project</h2>
<p>Cloudflare Pages has two ways to get a site onto it: connect a Git repo and
let Cloudflare run the build, or <a href="https://developers.cloudflare.com/pages/get-started/direct-upload/" target="_blank" rel="noopener noreferrer">direct upload</a>
 a
folder you already built yourself. This post uses direct upload, since the
build already happens in GitHub Actions and there&rsquo;s no reason to run it a
second time on Cloudflare&rsquo;s side. A direct upload project can&rsquo;t be switched
to Git integration later, so decide up front if you might want Cloudflare
running the build too.</p>
<p>Log in and create the project with Wrangler:</p>
<div class="code-block">
  <div class="code-block-header">
    <span class="code-block-lang">shell</span>
    <button type="button" class="code-copy" aria-label="Copy code">
      <span class="code-copy-icon"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><rect x="9" y="9" width="13" height="13" rx="2" ry="2"/><path d="M5 15H4a2 2 0 01-2-2V4a2 2 0 012-2h9a2 2 0 012 2v1"/></svg>
</span>
      <span class="code-copy-icon code-copy-icon-check"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><polyline points="20 6 9 17 4 12"/></svg>
</span>
      <span class="code-copy-label">Copy</span>
    </button>
  </div>
  <div class="highlight"><pre tabindex="0" class="chroma"><code class="language-shell" data-lang="shell"><span class="line"><span class="cl">npx wrangler login
</span></span><span class="line"><span class="cl">npx wrangler pages project create</span></span></code></pre></div>
</div>
<p>It asks for a project name and a production branch, then the project exists
and is served at <code>&lt;PROJECT_NAME&gt;.pages.dev</code> until you attach a custom
domain.</p>
<h2 id="deploy-the-build-by-hand">Deploy the build by hand</h2>
<p>Build the site the same way as before:</p>
<div class="code-block">
  <div class="code-block-header">
    <span class="code-block-lang">shell</span>
    <button type="button" class="code-copy" aria-label="Copy code">
      <span class="code-copy-icon"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><rect x="9" y="9" width="13" height="13" rx="2" ry="2"/><path d="M5 15H4a2 2 0 01-2-2V4a2 2 0 012-2h9a2 2 0 012 2v1"/></svg>
</span>
      <span class="code-copy-icon code-copy-icon-check"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><polyline points="20 6 9 17 4 12"/></svg>
</span>
      <span class="code-copy-label">Copy</span>
    </button>
  </div>
  <div class="highlight"><pre tabindex="0" class="chroma"><code class="language-shell" data-lang="shell"><span class="line"><span class="cl">hugo --gc --minify</span></span></code></pre></div>
</div>
<p>Then push the <code>public/</code> directory up with Wrangler:</p>
<div class="code-block">
  <div class="code-block-header">
    <span class="code-block-lang">shell</span>
    <button type="button" class="code-copy" aria-label="Copy code">
      <span class="code-copy-icon"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><rect x="9" y="9" width="13" height="13" rx="2" ry="2"/><path d="M5 15H4a2 2 0 01-2-2V4a2 2 0 012-2h9a2 2 0 012 2v1"/></svg>
</span>
      <span class="code-copy-icon code-copy-icon-check"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><polyline points="20 6 9 17 4 12"/></svg>
</span>
      <span class="code-copy-label">Copy</span>
    </button>
  </div>
  <div class="highlight"><pre tabindex="0" class="chroma"><code class="language-shell" data-lang="shell"><span class="line"><span class="cl">npx wrangler pages deploy public --project-name<span class="o">=</span>&lt;PROJECT_NAME&gt;</span></span></code></pre></div>
</div>
<p>That&rsquo;s a full deploy on its own, useful the first time to make sure the
project and the build output line up before wiring up CI.</p>
<h2 id="automate-the-deploy-with-github-actions">Automate the deploy with GitHub Actions</h2>
<p>Deploying from a laptop works, but the point of the GitHub Pages post was
never touching the deploy step by hand again. Wrangler needs two values to
run from CI: an API token and the account ID, both from the Cloudflare
dashboard&rsquo;s <em>API Tokens</em> page and <em>Account Home</em> overview respectively.
Add them as repository secrets, <code>CLOUDFLARE_API_TOKEN</code> and
<code>CLOUDFLARE_ACCOUNT_ID</code>, under <em>Settings &gt; Secrets and variables &gt; Actions</em>.</p>
<p>The workflow reuses the Hugo install step from the GitHub Pages post, then
hands the build off to <a href="https://github.com/cloudflare/wrangler-action" target="_blank" rel="noopener noreferrer"><code>cloudflare/wrangler-action</code></a>

instead of <code>actions/deploy-pages</code>:</p>
<div class="code-block">
  <div class="code-block-header">
    <span class="code-block-lang">yaml</span>
    <button type="button" class="code-copy" aria-label="Copy code">
      <span class="code-copy-icon"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><rect x="9" y="9" width="13" height="13" rx="2" ry="2"/><path d="M5 15H4a2 2 0 01-2-2V4a2 2 0 012-2h9a2 2 0 012 2v1"/></svg>
</span>
      <span class="code-copy-icon code-copy-icon-check"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><polyline points="20 6 9 17 4 12"/></svg>
</span>
      <span class="code-copy-label">Copy</span>
    </button>
  </div>
  <div class="highlight"><pre tabindex="0" class="chroma"><code class="language-yaml" data-lang="yaml"><span class="line"><span class="cl"><span class="c"># .github/workflows/deploy-cloudflare.yaml</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="nt">name</span><span class="p">:</span><span class="w"> </span><span class="l">Deploy Hugo site to Cloudflare Pages</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="nt">on</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">  </span><span class="nt">push</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">branches</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span>- <span class="l">master</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">  </span><span class="nt">workflow_dispatch</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="nt">jobs</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">  </span><span class="nt">deploy</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">runs-on</span><span class="p">:</span><span class="w"> </span><span class="l">ubuntu-latest</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">permissions</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span><span class="nt">contents</span><span class="p">:</span><span class="w"> </span><span class="l">read</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span><span class="nt">deployments</span><span class="p">:</span><span class="w"> </span><span class="l">write</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">env</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span><span class="nt">HUGO_VERSION</span><span class="p">:</span><span class="w"> </span><span class="m">0.165.0</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">steps</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span>- <span class="nt">name</span><span class="p">:</span><span class="w"> </span><span class="l">Install Hugo CLI</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">        </span><span class="nt">run</span><span class="p">:</span><span class="w"> </span><span class="p">|</span><span class="sd">
</span></span></span><span class="line"><span class="cl"><span class="sd">          wget -O ${{ runner.temp }}/hugo.deb https://github.com/gohugoio/hugo/releases/download/v${HUGO_VERSION}/hugo_extended_${HUGO_VERSION}_linux-amd64.deb
</span></span></span><span class="line"><span class="cl"><span class="sd">          sudo dpkg -i ${{ runner.temp }}/hugo.deb</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span>- <span class="nt">name</span><span class="p">:</span><span class="w"> </span><span class="l">Checkout</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">        </span><span class="nt">uses</span><span class="p">:</span><span class="w"> </span><span class="l">actions/checkout@v7</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">        </span><span class="nt">with</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">          </span><span class="nt">fetch-depth</span><span class="p">:</span><span class="w"> </span><span class="m">0</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span>- <span class="nt">name</span><span class="p">:</span><span class="w"> </span><span class="l">Build with Hugo</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">        </span><span class="nt">env</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">          </span><span class="nt">HUGO_ENVIRONMENT</span><span class="p">:</span><span class="w"> </span><span class="l">production</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">          </span><span class="nt">HUGO_ENV</span><span class="p">:</span><span class="w"> </span><span class="l">production</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">        </span><span class="nt">run</span><span class="p">:</span><span class="w"> </span><span class="p">|</span><span class="sd">
</span></span></span><span class="line"><span class="cl"><span class="sd">          hugo --gc --minify</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span>- <span class="nt">name</span><span class="p">:</span><span class="w"> </span><span class="l">Deploy to Cloudflare Pages</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">        </span><span class="nt">uses</span><span class="p">:</span><span class="w"> </span><span class="l">cloudflare/wrangler-action@v3</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">        </span><span class="nt">with</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">          </span><span class="nt">apiToken</span><span class="p">:</span><span class="w"> </span><span class="l">${{ secrets.CLOUDFLARE_API_TOKEN }}</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">          </span><span class="nt">accountId</span><span class="p">:</span><span class="w"> </span><span class="l">${{ secrets.CLOUDFLARE_ACCOUNT_ID }}</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">          </span><span class="nt">command</span><span class="p">:</span><span class="w"> </span><span class="l">pages deploy public --project-name=&lt;PROJECT_NAME&gt;</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">          </span><span class="nt">gitHubToken</span><span class="p">:</span><span class="w"> </span><span class="l">${{ secrets.GITHUB_TOKEN }}</span></span></span></code></pre></div>
</div>
<p>One difference from the GitHub Pages workflow: there&rsquo;s no <code>--baseURL</code>
override on the build step. <code>actions/configure-pages</code> needed that because
GitHub can serve a project from a subpath. Cloudflare Pages always serves
from the domain root, so whatever <code>baseURL</code> is already set in <code>hugo.toml</code>
is correct as is.</p>
<pre class="mermaid" id="mermaid-4"></pre>
<script>(window.__mermaidSources ||= {})["mermaid-4"] = "flowchart TD\n  Push[\"git push\"] --\u003e Build[\"GitHub Actions: hugo --gc --minify\"]\n  Build --\u003e Deploy[\"wrangler pages deploy public\"]\n  Deploy --\u003e Pages[\"Cloudflare Pages\"]";</script>

<p>Push, and the Actions tab shows the same build-then-deploy shape as the
GitHub Pages workflow, just handing the finished files to Wrangler instead
of <code>actions/deploy-pages</code>.</p>
<h2 id="conclusion">Conclusion</h2>
<p>Direct upload is the whole difference here: GitHub Pages ties the
build to its own Pages integration, Cloudflare Pages with direct upload
doesn&rsquo;t care where <code>public/</code> came from, only that it&rsquo;s a folder of files
and a Wrangler command pointed at it. Same Hugo site, same GitHub Actions
trigger, just a different platform on the receiving end.</p>
<p>Between this post and the
<a href="https://omegion.dev/2024/02/how-to-host-a-personal-blog-with-hugo-on-github-pages/">previous one</a>
,
that&rsquo;s a Hugo site deployed for free both ways, GitHub Pages and Cloudflare
Pages. Pick whichever one the rest of your stack already leans on.</p>
]]></content:encoded></item><item><title>DBBackup</title><link>https://omegion.dev/projects/dbbackup/</link><pubDate>Fri, 01 Mar 2024 00:00:00 +0000</pubDate><guid>https://omegion.dev/projects/dbbackup/</guid><description>Automated, scheduled database backups to S3.</description><content:encoded>&lt;p>DBBackup automated backups for PostgreSQL, MySQL, MongoDB, and Redis, to
AWS S3, Google Cloud, or Azure. Two deployment modes: serverless, where it
connects to your database directly with no infrastructure to run, or
bring-your-own-server, connecting through your own host for more control.
There&amp;rsquo;s also a CLI for backing up to local disk, S3, or DBBackup.io
directly.&lt;/p>
</content:encoded></item><item><title>How to Host a Personal Blog with Hugo on GitHub Pages</title><link>https://omegion.dev/2024/02/how-to-host-a-personal-blog-with-hugo-on-github-pages/</link><pubDate>Tue, 13 Feb 2024 00:00:00 +0000</pubDate><guid>https://omegion.dev/2024/02/how-to-host-a-personal-blog-with-hugo-on-github-pages/</guid><description>Building a Hugo blog from scratch and hosting it for free on GitHub Pages - a new site, a first post, a GitHub Actions deploy, and a theme, the same setup this blog runs on.</description><content:encoded><![CDATA[<h2 id="introduction">Introduction</h2>
<p>I run EKS clusters and edge infrastructure for a living, and I still don&rsquo;t
want to run or pay for any of it on my own time. That&rsquo;s a rule I keep for
side projects: no self-hosting, no Kubernetes, no Cloudflare Workers, nothing
with a bill attached. When I started this blog in 2020 that rule meant two
decisions: what to write it in, and where to put it. I landed on Hugo and
GitHub Pages, and this post is the whole path from an empty folder to a live
site, the same setup <a href="https://omegion.dev">omegion.dev</a>
 runs on today.</p>
<h2 id="prerequisites">Prerequisites</h2>
<ol>
<li><a href="https://gohugo.io/installation/" target="_blank" rel="noopener noreferrer">Hugo Extended</a>
, <code>0.146.0</code> or newer. On
macOS: <code>brew install hugo</code>.</li>
<li>Git.</li>
<li>A GitHub account, and an empty repository created there to push this
site into.</li>
</ol>
<h2 id="what-is-hugo">What is Hugo</h2>
<p>Hugo is a static site generator written in Go. You write content in
Markdown, Hugo runs it through a set of templates, and the output is plain
HTML files, nothing server-side, no database, no request-time rendering. It
ships as a single binary, no Node toolchain, no Ruby, nothing to install
beyond the binary itself. I&rsquo;m a Go person, so that was an easy sell on its
own, but the part that actually matters for &ldquo;no infrastructure&rdquo; is the
output: a folder of HTML files is the cheapest possible thing to host,
because it doesn&rsquo;t need a running process at all.</p>
<h2 id="create-a-new-site">Create a new site</h2>
<div class="code-block">
  <div class="code-block-header">
    <span class="code-block-lang">shell</span>
    <button type="button" class="code-copy" aria-label="Copy code">
      <span class="code-copy-icon"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><rect x="9" y="9" width="13" height="13" rx="2" ry="2"/><path d="M5 15H4a2 2 0 01-2-2V4a2 2 0 012-2h9a2 2 0 012 2v1"/></svg>
</span>
      <span class="code-copy-icon code-copy-icon-check"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><polyline points="20 6 9 17 4 12"/></svg>
</span>
      <span class="code-copy-label">Copy</span>
    </button>
  </div>
  <div class="highlight"><pre tabindex="0" class="chroma"><code class="language-shell" data-lang="shell"><span class="line"><span class="cl">❯ hugo new site myblog
</span></span><span class="line"><span class="cl">Congratulations! Your new Hugo project was created in /Users/hakan/myblog.
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl">Just a few more steps...
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl">1. Change the current directory to /Users/hakan/myblog.
</span></span><span class="line"><span class="cl">2. Create or install a theme:
</span></span><span class="line"><span class="cl">   - Create a new theme with the <span class="nb">command</span> <span class="s2">&#34;hugo new theme &lt;THEMENAME&gt;&#34;</span>
</span></span><span class="line"><span class="cl">   - Or, install a theme from https://themes.gohugo.io/
</span></span><span class="line"><span class="cl">3. Edit hugo.toml, setting the <span class="s2">&#34;theme&#34;</span> property to the theme name.
</span></span><span class="line"><span class="cl">4. Create new content with the <span class="nb">command</span> <span class="s2">&#34;hugo new content &lt;SECTIONNAME&gt;/&lt;FILENAME&gt;.&lt;FORMAT&gt;&#34;</span>.
</span></span><span class="line"><span class="cl">5. Start the embedded web server with the <span class="nb">command</span> <span class="s2">&#34;hugo server --buildDrafts&#34;</span>.
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl">See documentation at https://gohugo.io/.</span></span></code></pre></div>
</div>
<p>That scaffolds <code>content/</code>, <code>layouts/</code>, <code>static/</code>, <code>archetypes/</code>, and a
<code>hugo.toml</code> with just a <code>baseURL</code>, <code>title</code>, and <code>locale</code>. <code>cd myblog</code> and
turn it into a git repo:</p>
<div class="code-block">
  <div class="code-block-header">
    <span class="code-block-lang">shell</span>
    <button type="button" class="code-copy" aria-label="Copy code">
      <span class="code-copy-icon"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><rect x="9" y="9" width="13" height="13" rx="2" ry="2"/><path d="M5 15H4a2 2 0 01-2-2V4a2 2 0 012-2h9a2 2 0 012 2v1"/></svg>
</span>
      <span class="code-copy-icon code-copy-icon-check"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><polyline points="20 6 9 17 4 12"/></svg>
</span>
      <span class="code-copy-label">Copy</span>
    </button>
  </div>
  <div class="highlight"><pre tabindex="0" class="chroma"><code class="language-shell" data-lang="shell"><span class="line"><span class="cl">git init
</span></span><span class="line"><span class="cl">git add .
</span></span><span class="line"><span class="cl">git commit -m <span class="s2">&#34;Initial commit&#34;</span></span></span></code></pre></div>
</div>
<h2 id="write-your-first-post">Write your first post</h2>
<p><code>hugo new content</code> uses the default archetype to stamp out a new page with
frontmatter already filled in:</p>
<div class="code-block">
  <div class="code-block-header">
    <span class="code-block-lang">shell</span>
    <button type="button" class="code-copy" aria-label="Copy code">
      <span class="code-copy-icon"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><rect x="9" y="9" width="13" height="13" rx="2" ry="2"/><path d="M5 15H4a2 2 0 01-2-2V4a2 2 0 012-2h9a2 2 0 012 2v1"/></svg>
</span>
      <span class="code-copy-icon code-copy-icon-check"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><polyline points="20 6 9 17 4 12"/></svg>
</span>
      <span class="code-copy-label">Copy</span>
    </button>
  </div>
  <div class="highlight"><pre tabindex="0" class="chroma"><code class="language-shell" data-lang="shell"><span class="line"><span class="cl">❯ hugo new content posts/hello-world.md
</span></span><span class="line"><span class="cl">Content <span class="s2">&#34;/Users/hakan/myblog/content/posts/hello-world.md&#34;</span> created</span></span></code></pre></div>
</div>
<div class="code-block">
  <div class="code-block-header">
    <span class="code-block-lang">toml</span>
    <button type="button" class="code-copy" aria-label="Copy code">
      <span class="code-copy-icon"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><rect x="9" y="9" width="13" height="13" rx="2" ry="2"/><path d="M5 15H4a2 2 0 01-2-2V4a2 2 0 012-2h9a2 2 0 012 2v1"/></svg>
</span>
      <span class="code-copy-icon code-copy-icon-check"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><polyline points="20 6 9 17 4 12"/></svg>
</span>
      <span class="code-copy-label">Copy</span>
    </button>
  </div>
  <div class="highlight"><pre tabindex="0" class="chroma"><code class="language-toml" data-lang="toml"><span class="line"><span class="cl"><span class="err">+++</span>
</span></span><span class="line"><span class="cl"><span class="nx">date</span> <span class="p">=</span> <span class="s1">&#39;2026-08-27T08:56:30+02:00&#39;</span>
</span></span><span class="line"><span class="cl"><span class="nx">draft</span> <span class="p">=</span> <span class="kc">true</span>
</span></span><span class="line"><span class="cl"><span class="nx">title</span> <span class="p">=</span> <span class="s1">&#39;Hello World&#39;</span>
</span></span><span class="line"><span class="cl"><span class="err">+++</span></span></span></code></pre></div>
</div>
<p>Add a line or two of body text under that frontmatter, and flip <code>draft</code> to
<code>false</code> once you actually want it live, Hugo skips draft content by default
outside of local preview.</p>
<h2 id="build-it-locally">Build it locally</h2>
<p><code>hugo server --buildDrafts</code> runs a local dev server with live reload:</p>
<div class="code-block">
  <div class="code-block-header">
    <span class="code-block-lang">shell</span>
    <button type="button" class="code-copy" aria-label="Copy code">
      <span class="code-copy-icon"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><rect x="9" y="9" width="13" height="13" rx="2" ry="2"/><path d="M5 15H4a2 2 0 01-2-2V4a2 2 0 012-2h9a2 2 0 012 2v1"/></svg>
</span>
      <span class="code-copy-icon code-copy-icon-check"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><polyline points="20 6 9 17 4 12"/></svg>
</span>
      <span class="code-copy-label">Copy</span>
    </button>
  </div>
  <div class="highlight"><pre tabindex="0" class="chroma"><code class="language-shell" data-lang="shell"><span class="line"><span class="cl">❯ hugo server --buildDrafts
</span></span><span class="line"><span class="cl">Watching <span class="k">for</span> changes in /Users/hakan/myblog/<span class="o">{</span>archetypes,assets,content,data,i18n,layouts,static<span class="o">}</span>
</span></span><span class="line"><span class="cl">Watching <span class="k">for</span> config changes in /Users/hakan/myblog/hugo.toml
</span></span><span class="line"><span class="cl">Start building sites …
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl">WARN  found no layout file <span class="k">for</span> <span class="s2">&#34;html&#34;</span> <span class="k">for</span> kind <span class="s2">&#34;home&#34;</span>: You should create a template file which matches Hugo Layouts Lookup Rules <span class="k">for</span> this combination.
</span></span><span class="line"><span class="cl">WARN  found no layout file <span class="k">for</span> <span class="s2">&#34;html&#34;</span> <span class="k">for</span> kind <span class="s2">&#34;page&#34;</span>: You should create a template file which matches Hugo Layouts Lookup Rules <span class="k">for</span> this combination.
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl">                  │ EN
</span></span><span class="line"><span class="cl">──────────────────┼────
</span></span><span class="line"><span class="cl"> Pages            │  <span class="m">5</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl">Web Server is available at http://localhost:1313/
</span></span><span class="line"><span class="cl">Press Ctrl+C to stop</span></span></code></pre></div>
</div>
<p>Those <code>WARN</code> lines are expected right now. A freshly scaffolded site has no
theme, so there&rsquo;s nothing telling Hugo how to turn a post into a page. It&rsquo;ll
build, the server will start, and the browser tab will be blank. That gets
fixed in the last section, once the deploy pipeline is in place.</p>
<p>For a real build instead of the dev server:</p>
<div class="code-block">
  <div class="code-block-header">
    <span class="code-block-lang">shell</span>
    <button type="button" class="code-copy" aria-label="Copy code">
      <span class="code-copy-icon"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><rect x="9" y="9" width="13" height="13" rx="2" ry="2"/><path d="M5 15H4a2 2 0 01-2-2V4a2 2 0 012-2h9a2 2 0 012 2v1"/></svg>
</span>
      <span class="code-copy-icon code-copy-icon-check"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><polyline points="20 6 9 17 4 12"/></svg>
</span>
      <span class="code-copy-label">Copy</span>
    </button>
  </div>
  <div class="highlight"><pre tabindex="0" class="chroma"><code class="language-shell" data-lang="shell"><span class="line"><span class="cl">❯ hugo --gc --minify</span></span></code></pre></div>
</div>
<p>That writes the finished site to <code>public/</code>, the same directory the deploy
workflow below picks up and publishes.</p>
<h2 id="deploy-it-to-github-pages-with-github-actions">Deploy it to GitHub Pages with GitHub Actions</h2>
<p>Push the repo to GitHub, then add a workflow that builds the site and
deploys it on every push, this is the exact file I run for this blog:</p>
<div class="code-block">
  <div class="code-block-header">
    <span class="code-block-lang">yaml</span>
    <button type="button" class="code-copy" aria-label="Copy code">
      <span class="code-copy-icon"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><rect x="9" y="9" width="13" height="13" rx="2" ry="2"/><path d="M5 15H4a2 2 0 01-2-2V4a2 2 0 012-2h9a2 2 0 012 2v1"/></svg>
</span>
      <span class="code-copy-icon code-copy-icon-check"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><polyline points="20 6 9 17 4 12"/></svg>
</span>
      <span class="code-copy-label">Copy</span>
    </button>
  </div>
  <div class="highlight"><pre tabindex="0" class="chroma"><code class="language-yaml" data-lang="yaml"><span class="line"><span class="cl"><span class="c"># .github/workflows/deploy.yaml</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="nt">name</span><span class="p">:</span><span class="w"> </span><span class="l">Deploy Hugo site to Pages</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="nt">on</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">  </span><span class="nt">push</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">branches</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span>- <span class="l">master</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">  </span><span class="nt">workflow_dispatch</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="nt">permissions</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">  </span><span class="nt">contents</span><span class="p">:</span><span class="w"> </span><span class="l">read</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">  </span><span class="nt">pages</span><span class="p">:</span><span class="w"> </span><span class="l">write</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">  </span><span class="nt">id-token</span><span class="p">:</span><span class="w"> </span><span class="l">write</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="nt">concurrency</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">  </span><span class="nt">group</span><span class="p">:</span><span class="w"> </span><span class="s2">&#34;pages&#34;</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">  </span><span class="nt">cancel-in-progress</span><span class="p">:</span><span class="w"> </span><span class="kc">false</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="nt">jobs</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">  </span><span class="nt">build</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">runs-on</span><span class="p">:</span><span class="w"> </span><span class="l">ubuntu-latest</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">env</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span><span class="nt">HUGO_VERSION</span><span class="p">:</span><span class="w"> </span><span class="m">0.165.0</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">steps</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span>- <span class="nt">name</span><span class="p">:</span><span class="w"> </span><span class="l">Install Hugo CLI</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">        </span><span class="nt">run</span><span class="p">:</span><span class="w"> </span><span class="p">|</span><span class="sd">
</span></span></span><span class="line"><span class="cl"><span class="sd">          wget -O ${{ runner.temp }}/hugo.deb https://github.com/gohugoio/hugo/releases/download/v${HUGO_VERSION}/hugo_extended_${HUGO_VERSION}_linux-amd64.deb
</span></span></span><span class="line"><span class="cl"><span class="sd">          sudo dpkg -i ${{ runner.temp }}/hugo.deb</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span>- <span class="nt">name</span><span class="p">:</span><span class="w"> </span><span class="l">Checkout</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">        </span><span class="nt">uses</span><span class="p">:</span><span class="w"> </span><span class="l">actions/checkout@v7</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">        </span><span class="nt">with</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">          </span><span class="nt">fetch-depth</span><span class="p">:</span><span class="w"> </span><span class="m">0</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span>- <span class="nt">name</span><span class="p">:</span><span class="w"> </span><span class="l">Setup Pages</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">        </span><span class="nt">id</span><span class="p">:</span><span class="w"> </span><span class="l">pages</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">        </span><span class="nt">uses</span><span class="p">:</span><span class="w"> </span><span class="l">actions/configure-pages@v6</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span>- <span class="nt">name</span><span class="p">:</span><span class="w"> </span><span class="l">Build with Hugo</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">        </span><span class="nt">env</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">          </span><span class="nt">HUGO_ENVIRONMENT</span><span class="p">:</span><span class="w"> </span><span class="l">production</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">          </span><span class="nt">HUGO_ENV</span><span class="p">:</span><span class="w"> </span><span class="l">production</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">        </span><span class="nt">run</span><span class="p">:</span><span class="w"> </span><span class="p">|</span><span class="sd">
</span></span></span><span class="line"><span class="cl"><span class="sd">          hugo --gc --minify --baseURL &#34;${{ steps.pages.outputs.base_url }}/&#34;</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span>- <span class="nt">name</span><span class="p">:</span><span class="w"> </span><span class="l">Upload artifact</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">        </span><span class="nt">uses</span><span class="p">:</span><span class="w"> </span><span class="l">actions/upload-pages-artifact@v5</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">        </span><span class="nt">with</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">          </span><span class="nt">path</span><span class="p">:</span><span class="w"> </span><span class="l">./public</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">  </span><span class="nt">deploy</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">environment</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span><span class="nt">name</span><span class="p">:</span><span class="w"> </span><span class="l">github-pages</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span><span class="nt">url</span><span class="p">:</span><span class="w"> </span><span class="l">${{ steps.deployment.outputs.page_url }}</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">runs-on</span><span class="p">:</span><span class="w"> </span><span class="l">ubuntu-latest</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">needs</span><span class="p">:</span><span class="w"> </span><span class="l">build</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">steps</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span>- <span class="nt">name</span><span class="p">:</span><span class="w"> </span><span class="l">Deploy to GitHub Pages</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">        </span><span class="nt">id</span><span class="p">:</span><span class="w"> </span><span class="l">deployment</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">        </span><span class="nt">uses</span><span class="p">:</span><span class="w"> </span><span class="l">actions/deploy-pages@v5</span></span></span></code></pre></div>
</div>
<p><code>fetch-depth: 0</code> in the checkout step matters if you turn on
<code>enableGitInfo</code> later. Without full history Hugo can&rsquo;t read a page&rsquo;s last
commit date. One setting has to happen by hand: in the repo&rsquo;s <em>Settings &gt;
Pages</em>, set <em>Source</em> to <em>GitHub Actions</em>, not <em>Deploy from a branch</em>. That&rsquo;s
what lets <code>actions/deploy-pages</code> publish without a <code>gh-pages</code> branch or a
committed <code>CNAME</code> file at all. A custom domain is set in that same <em>Pages</em>
screen. GitHub writes and manages the <code>CNAME</code> file for you from there.</p>
<pre class="mermaid" id="mermaid-7"></pre>
<script>(window.__mermaidSources ||= {})["mermaid-7"] = "flowchart TD\n  Push[\"git push\"] --\u003e Build[\"GitHub Actions: hugo --minify\"]\n  Build --\u003e Artifact[\"Pages artifact (public/)\"]\n  Artifact --\u003e Pages[\"GitHub Pages\"]";</script>

<p>Push, and the Actions tab shows the build and deploy jobs running. A few
minutes later the site is live at your GitHub Pages URL, or your custom
domain if you set one, no server, no SSH key, no FTP, just Actions minutes
GitHub already gives away for free on a public repo.</p>
<h2 id="add-a-theme">Add a theme</h2>
<p>This is the piece that fixes the blank page and <code>WARN</code> lines from earlier.
Hugo doesn&rsquo;t ship with a default look, a theme is what supplies the actual
templates. <a href="https://themes.gohugo.io/" target="_blank" rel="noopener noreferrer">themes.gohugo.io</a>
 has a few hundred
to pick from, or you can build your own the way I did:
<a href="https://github.com/omegion/hugo-omegion" target="_blank" rel="noopener noreferrer">hugo-omegion</a>
, the theme this
blog runs. Sidebar-first layout, dark/light mode, client-side search with
no backend, Mermaid diagrams, syntax-highlighted code blocks with a copy
button, RSS, and the SEO/OpenGraph/JSON-LD metadata a blog actually needs.
MIT licensed, free to take.</p>
<p>Whichever theme you pick, I&rsquo;d recommend installing it as a Hugo Module
instead of the classic git submodule under <code>themes/</code>, it&rsquo;s one version
pinned in <code>go.mod</code> like any other dependency, instead of a submodule
pointer nobody remembers to update:</p>
<div class="code-block">
  <div class="code-block-header">
    <span class="code-block-lang">shell</span>
    <button type="button" class="code-copy" aria-label="Copy code">
      <span class="code-copy-icon"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><rect x="9" y="9" width="13" height="13" rx="2" ry="2"/><path d="M5 15H4a2 2 0 01-2-2V4a2 2 0 012-2h9a2 2 0 012 2v1"/></svg>
</span>
      <span class="code-copy-icon code-copy-icon-check"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><polyline points="20 6 9 17 4 12"/></svg>
</span>
      <span class="code-copy-label">Copy</span>
    </button>
  </div>
  <div class="highlight"><pre tabindex="0" class="chroma"><code class="language-shell" data-lang="shell"><span class="line"><span class="cl">hugo mod init github.com/&lt;you&gt;/myblog</span></span></code></pre></div>
</div>
<div class="code-block">
  <div class="code-block-header">
    <span class="code-block-lang">toml</span>
    <button type="button" class="code-copy" aria-label="Copy code">
      <span class="code-copy-icon"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><rect x="9" y="9" width="13" height="13" rx="2" ry="2"/><path d="M5 15H4a2 2 0 01-2-2V4a2 2 0 012-2h9a2 2 0 012 2v1"/></svg>
</span>
      <span class="code-copy-icon code-copy-icon-check"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><polyline points="20 6 9 17 4 12"/></svg>
</span>
      <span class="code-copy-label">Copy</span>
    </button>
  </div>
  <div class="highlight"><pre tabindex="0" class="chroma"><code class="language-toml" data-lang="toml"><span class="line"><span class="cl"><span class="c"># hugo.toml</span>
</span></span><span class="line"><span class="cl"><span class="nx">enableEmoji</span> <span class="p">=</span> <span class="kc">true</span>
</span></span><span class="line"><span class="cl"><span class="nx">enableGitInfo</span> <span class="p">=</span> <span class="kc">true</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="p">[[</span><span class="nx">module</span><span class="p">.</span><span class="nx">imports</span><span class="p">]]</span>
</span></span><span class="line"><span class="cl"><span class="nx">path</span> <span class="p">=</span> <span class="s1">&#39;github.com/omegion/hugo-omegion&#39;</span></span></span></code></pre></div>
</div>
<div class="code-block">
  <div class="code-block-header">
    <span class="code-block-lang">shell</span>
    <button type="button" class="code-copy" aria-label="Copy code">
      <span class="code-copy-icon"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><rect x="9" y="9" width="13" height="13" rx="2" ry="2"/><path d="M5 15H4a2 2 0 01-2-2V4a2 2 0 012-2h9a2 2 0 012 2v1"/></svg>
</span>
      <span class="code-copy-icon code-copy-icon-check"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><polyline points="20 6 9 17 4 12"/></svg>
</span>
      <span class="code-copy-label">Copy</span>
    </button>
  </div>
  <div class="highlight"><pre tabindex="0" class="chroma"><code class="language-shell" data-lang="shell"><span class="line"><span class="cl">❯ hugo mod tidy
</span></span><span class="line"><span class="cl">go: added github.com/omegion/hugo-omegion v1.0.16
</span></span><span class="line"><span class="cl">hugo: collected modules in <span class="m">607</span> ms</span></span></code></pre></div>
</div>
<p>Rebuilding after that pulls in the theme&rsquo;s <code>layouts/</code>, <code>assets/</code>, and
<code>static/</code>, the <code>WARN</code> lines are gone and the page count jumps once there&rsquo;s
an actual template rendering each post:</p>
<div class="code-block">
  <div class="code-block-header">
    <span class="code-block-lang">shell</span>
    <button type="button" class="code-copy" aria-label="Copy code">
      <span class="code-copy-icon"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><rect x="9" y="9" width="13" height="13" rx="2" ry="2"/><path d="M5 15H4a2 2 0 01-2-2V4a2 2 0 012-2h9a2 2 0 012 2v1"/></svg>
</span>
      <span class="code-copy-icon code-copy-icon-check"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><polyline points="20 6 9 17 4 12"/></svg>
</span>
      <span class="code-copy-label">Copy</span>
    </button>
  </div>
  <div class="highlight"><pre tabindex="0" class="chroma"><code class="language-shell" data-lang="shell"><span class="line"><span class="cl">❯ hugo --buildDrafts
</span></span><span class="line"><span class="cl">                  │ EN
</span></span><span class="line"><span class="cl">──────────────────┼────
</span></span><span class="line"><span class="cl"> Pages            │ <span class="m">11</span></span></span></code></pre></div>
</div>
<p>What a theme doesn&rsquo;t inherit automatically is the site&rsquo;s own config.
<code>enableGitInfo</code>, the <code>searchindex</code> output format a search box needs, and
<code>goldmark.renderer.unsafe = true</code> for raw HTML in posts all have to be set
in your own <code>hugo.toml</code>, not the theme&rsquo;s. hugo-omegion&rsquo;s README spells out
the exact block to copy. Skip one and the symptom is quiet: a blank search
box, no &ldquo;Updated&rdquo; date on a post, nothing that looks like an error.</p>
<p>Bumping the theme later is <code>hugo mod get -u github.com/omegion/hugo-omegion</code>,
a normal diff in <code>go.mod</code> and <code>go.sum</code>, the same motion as updating any
other Go module.</p>
<h2 id="conclusion">Conclusion</h2>
<p>That&rsquo;s the whole setup: a new Hugo site, a first post, a GitHub Actions
workflow that builds and deploys it on every push, and a theme on top. No
server to run, no database, nothing to patch, and no bill at the end of
the month. Push a commit and the site is live a few minutes later. For a
personal blog, that&rsquo;s exactly the amount of infrastructure I wanted: none.</p>
]]></content:encoded></item><item><title>Talos OS: Running Postgres with CloudNativePG on Ceph</title><link>https://omegion.dev/2023/11/talos-os-running-postgres-with-cloudnativepg-on-ceph/</link><pubDate>Wed, 08 Nov 2023 00:00:00 +0000</pubDate><guid>https://omegion.dev/2023/11/talos-os-running-postgres-with-cloudnativepg-on-ceph/</guid><description>Running a CloudNativePG cluster backed by Rook Ceph on Talos, with scheduled Barman Cloud backups to S3.</description><content:encoded><![CDATA[<h2 id="introduction">Introduction</h2>
<p>The <a href="https://omegion.dev/2023/03/talos-os-persistent-volumes-with-rook-ceph/">previous post</a>

got me a <code>ceph-rbd</code> <code>StorageClass</code> backed by three-way replicated storage
across the cluster. The thing I actually wanted it for is Postgres, so this
post is a CloudNativePG (CNPG) cluster on top of it, with automated backups
to S3 through the Barman Cloud plugin.</p>
<h2 id="prerequisites">Prerequisites</h2>
<ol>
<li>A working <code>ceph-rbd</code> default <code>StorageClass</code> from the
<a href="https://omegion.dev/2023/03/talos-os-persistent-volumes-with-rook-ceph/">previous post</a>
.</li>
<li><a href="https://helm.sh/docs/intro/install/" target="_blank" rel="noopener noreferrer">Helm 3</a>
.</li>
<li>An S3-compatible bucket and credentials for backups.</li>
</ol>
<h2 id="what-is-cloudnativepg">What Is CloudNativePG</h2>
<p><a href="https://cloudnative-pg.io/" target="_blank" rel="noopener noreferrer">CloudNativePG</a>
 (CNPG) is a Kubernetes operator
for running Postgres - you describe the cluster you want as a <code>Cluster</code>
custom resource and it handles the rest: provisioning the instances,
promoting a replica if the primary dies, wiring up roles and databases
declaratively, and driving backups through a plugin like Barman Cloud. The
part I actually wanted it for is not having to hand-roll failover myself -
before this I&rsquo;d have needed a <code>StatefulSet</code>, my own replication setup, and
something watching the primary to promote a replica if it went down. CNPG
does all of that from one CR.</p>
<pre class="mermaid" id="mermaid-0"></pre>
<script>(window.__mermaidSources ||= {})["mermaid-0"] = "flowchart TD\n  Client[\"psql client\"] --\u003e PG[\"Postgres - CNPG\"]\n  PG --\u003e PVC[\"PVC - ceph-rbd\"]\n  PG --\u003e Barman[\"Barman Cloud plugin\"]\n  Barman --\u003e S3[\"S3 bucket\"]";</script>

<h2 id="installing-the-operator">Installing the Operator</h2>
<p>CNPG needs <code>cert-manager</code> for its webhooks, plus the Barman Cloud plugin,
which has to run in the same namespace as the CNPG operator itself. I wrap
all of that - <code>cert-manager</code>, the operator, the backup plugin, and the
cluster chart from the next section - as dependencies in my own <code>postgres</code>
chart, so the whole thing lands as one Helm release. This part of the
values file is the operator side:</p>
<div class="code-block">
  <div class="code-block-header">
    <span class="code-block-lang">yaml</span>
    <button type="button" class="code-copy" aria-label="Copy code">
      <span class="code-copy-icon"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><rect x="9" y="9" width="13" height="13" rx="2" ry="2"/><path d="M5 15H4a2 2 0 01-2-2V4a2 2 0 012-2h9a2 2 0 012 2v1"/></svg>
</span>
      <span class="code-copy-icon code-copy-icon-check"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><polyline points="20 6 9 17 4 12"/></svg>
</span>
      <span class="code-copy-label">Copy</span>
    </button>
  </div>
  <div class="highlight"><pre tabindex="0" class="chroma"><code class="language-yaml" data-lang="yaml"><span class="line"><span class="cl"><span class="nt">cert-manager</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">  </span><span class="nt">crds</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">enabled</span><span class="p">:</span><span class="w"> </span><span class="kc">true</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="nt">cloudnative-pg</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">  </span><span class="nt">config</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">clusterWide</span><span class="p">:</span><span class="w"> </span><span class="kc">true</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="nt">plugin-barman-cloud</span><span class="p">:</span><span class="w"> </span>{}</span></span></code></pre></div>
</div>
<p><code>clusterWide: true</code> on the operator means it watches <code>Cluster</code> resources in
every namespace, not just its own - useful once more than one app needs its
own database.</p>
<h2 id="the-cluster">The Cluster</h2>
<p>I run this through the CNPG project&rsquo;s own <code>cluster</code> chart, using its
TimescaleDB packaging (Postgres 17 + the TimescaleDB extension bundled in,
even though I&rsquo;m not using hypertables yet - it was just the variant I
standardized on):</p>
<div class="code-block">
  <div class="code-block-header">
    <span class="code-block-lang">yaml</span>
    <button type="button" class="code-copy" aria-label="Copy code">
      <span class="code-copy-icon"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><rect x="9" y="9" width="13" height="13" rx="2" ry="2"/><path d="M5 15H4a2 2 0 01-2-2V4a2 2 0 012-2h9a2 2 0 012 2v1"/></svg>
</span>
      <span class="code-copy-icon code-copy-icon-check"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><polyline points="20 6 9 17 4 12"/></svg>
</span>
      <span class="code-copy-label">Copy</span>
    </button>
  </div>
  <div class="highlight"><pre tabindex="0" class="chroma"><code class="language-yaml" data-lang="yaml"><span class="line"><span class="cl"><span class="nt">shared</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">  </span><span class="nt">enabled</span><span class="p">:</span><span class="w"> </span><span class="kc">true</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">  </span><span class="nt">type</span><span class="p">:</span><span class="w"> </span><span class="l">timescaledb</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">  </span><span class="nt">version</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">postgresql</span><span class="p">:</span><span class="w"> </span><span class="s2">&#34;17&#34;</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">timescaledb</span><span class="p">:</span><span class="w"> </span><span class="s2">&#34;2.17&#34;</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">  </span><span class="nt">mode</span><span class="p">:</span><span class="w"> </span><span class="l">standalone  </span><span class="w"> </span><span class="c"># fresh cluster, not a recovery</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">  </span><span class="nt">cluster</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">instances</span><span class="p">:</span><span class="w"> </span><span class="m">1</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">storage</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span><span class="nt">size</span><span class="p">:</span><span class="w"> </span><span class="l">100Gi</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span><span class="nt">storageClass</span><span class="p">:</span><span class="w"> </span><span class="s2">&#34;&#34;</span><span class="w">   </span><span class="c"># empty = cluster default StorageClass = ceph-rbd</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">resources</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span><span class="nt">requests</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">        </span><span class="nt">cpu</span><span class="p">:</span><span class="w"> </span><span class="s2">&#34;1&#34;</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">        </span><span class="nt">memory</span><span class="p">:</span><span class="w"> </span><span class="l">2Gi</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span><span class="nt">limits</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">        </span><span class="nt">cpu</span><span class="p">:</span><span class="w"> </span><span class="s2">&#34;2&#34;</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">        </span><span class="nt">memory</span><span class="p">:</span><span class="w"> </span><span class="l">4Gi</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">plugins</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span>- <span class="nt">name</span><span class="p">:</span><span class="w"> </span><span class="l">barman-cloud.cloudnative-pg.io</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">        </span><span class="nt">enabled</span><span class="p">:</span><span class="w"> </span><span class="kc">true</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">        </span><span class="nt">isWALArchiver</span><span class="p">:</span><span class="w"> </span><span class="kc">true</span></span></span></code></pre></div>
</div>
<p>With both pieces of the values file in place, one <code>helm install</code> brings up
<code>cert-manager</code>, the CNPG operator, the Barman Cloud plugin, and the
Postgres cluster itself together:</p>
<div class="code-block">
  <div class="code-block-header">
    <span class="code-block-lang">shell</span>
    <button type="button" class="code-copy" aria-label="Copy code">
      <span class="code-copy-icon"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><rect x="9" y="9" width="13" height="13" rx="2" ry="2"/><path d="M5 15H4a2 2 0 01-2-2V4a2 2 0 012-2h9a2 2 0 012 2v1"/></svg>
</span>
      <span class="code-copy-icon code-copy-icon-check"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><polyline points="20 6 9 17 4 12"/></svg>
</span>
      <span class="code-copy-label">Copy</span>
    </button>
  </div>
  <div class="highlight"><pre tabindex="0" class="chroma"><code class="language-shell" data-lang="shell"><span class="line"><span class="cl">❯ helm dependency build
</span></span><span class="line"><span class="cl">❯ helm install postgres . -n postgres --create-namespace -f values.yaml</span></span></code></pre></div>
</div>
<p>Leaving <code>storageClass</code> empty is deliberate - it falls back to whatever the
cluster&rsquo;s default <code>StorageClass</code> is, so I don&rsquo;t have to hardcode <code>ceph-rbd</code>
in two places. <code>mode: standalone</code> is what makes this a fresh cluster instead
of a restore; flipping that field plus a <code>bootstrap.recovery</code> block pointed
at the same backup location is how I&rsquo;d stand up a replacement cluster from
backups later.</p>
<h2 id="a-test-role-and-database">A Test Role and Database</h2>
<p>CNPG can create roles and databases declaratively instead of running SQL by
hand:</p>
<div class="code-block">
  <div class="code-block-header">
    <span class="code-block-lang">yaml</span>
    <button type="button" class="code-copy" aria-label="Copy code">
      <span class="code-copy-icon"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><rect x="9" y="9" width="13" height="13" rx="2" ry="2"/><path d="M5 15H4a2 2 0 01-2-2V4a2 2 0 012-2h9a2 2 0 012 2v1"/></svg>
</span>
      <span class="code-copy-icon code-copy-icon-check"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><polyline points="20 6 9 17 4 12"/></svg>
</span>
      <span class="code-copy-label">Copy</span>
    </button>
  </div>
  <div class="highlight"><pre tabindex="0" class="chroma"><code class="language-yaml" data-lang="yaml"><span class="line"><span class="cl"><span class="w">  </span><span class="nt">cluster</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">roles</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span>- <span class="nt">name</span><span class="p">:</span><span class="w"> </span><span class="l">demo</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">        </span><span class="nt">ensure</span><span class="p">:</span><span class="w"> </span><span class="l">present</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">        </span><span class="nt">login</span><span class="p">:</span><span class="w"> </span><span class="kc">true</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">        </span><span class="nt">superuser</span><span class="p">:</span><span class="w"> </span><span class="kc">false</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">        </span><span class="nt">createdb</span><span class="p">:</span><span class="w"> </span><span class="kc">false</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">        </span><span class="nt">connectionLimit</span><span class="p">:</span><span class="w"> </span>-<span class="m">1</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">        </span><span class="nt">inherit</span><span class="p">:</span><span class="w"> </span><span class="kc">true</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">        </span><span class="nt">passwordSecret</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">          </span><span class="nt">name</span><span class="p">:</span><span class="w"> </span><span class="l">demo-postgres-credentials</span></span></span></code></pre></div>
</div>
<div class="code-block">
  <div class="code-block-header">
    <span class="code-block-lang">yaml</span>
    <button type="button" class="code-copy" aria-label="Copy code">
      <span class="code-copy-icon"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><rect x="9" y="9" width="13" height="13" rx="2" ry="2"/><path d="M5 15H4a2 2 0 01-2-2V4a2 2 0 012-2h9a2 2 0 012 2v1"/></svg>
</span>
      <span class="code-copy-icon code-copy-icon-check"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><polyline points="20 6 9 17 4 12"/></svg>
</span>
      <span class="code-copy-label">Copy</span>
    </button>
  </div>
  <div class="highlight"><pre tabindex="0" class="chroma"><code class="language-yaml" data-lang="yaml"><span class="line"><span class="cl"><span class="nt">apiVersion</span><span class="p">:</span><span class="w"> </span><span class="l">postgresql.cnpg.io/v1</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="nt">kind</span><span class="p">:</span><span class="w"> </span><span class="l">Database</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="nt">metadata</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">  </span><span class="nt">name</span><span class="p">:</span><span class="w"> </span><span class="l">demo</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="nt">spec</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">  </span><span class="nt">name</span><span class="p">:</span><span class="w"> </span><span class="l">demo</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">  </span><span class="nt">owner</span><span class="p">:</span><span class="w"> </span><span class="l">demo</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">  </span><span class="nt">cluster</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">name</span><span class="p">:</span><span class="w"> </span><span class="l">postgres-cluster</span></span></span></code></pre></div>
</div>
<h2 id="connecting-to-it">Connecting to It</h2>
<p>To poke at it from inside the cluster, I run a throwaway client pod rather
than exec into the database pod directly:</p>
<div class="code-block">
  <div class="code-block-header">
    <span class="code-block-lang">yaml</span>
    <button type="button" class="code-copy" aria-label="Copy code">
      <span class="code-copy-icon"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><rect x="9" y="9" width="13" height="13" rx="2" ry="2"/><path d="M5 15H4a2 2 0 01-2-2V4a2 2 0 012-2h9a2 2 0 012 2v1"/></svg>
</span>
      <span class="code-copy-icon code-copy-icon-check"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><polyline points="20 6 9 17 4 12"/></svg>
</span>
      <span class="code-copy-label">Copy</span>
    </button>
  </div>
  <div class="highlight"><pre tabindex="0" class="chroma"><code class="language-yaml" data-lang="yaml"><span class="line"><span class="cl"><span class="nt">apiVersion</span><span class="p">:</span><span class="w"> </span><span class="l">v1</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="nt">kind</span><span class="p">:</span><span class="w"> </span><span class="l">Pod</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="nt">metadata</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">  </span><span class="nt">name</span><span class="p">:</span><span class="w"> </span><span class="l">psql-client</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="nt">spec</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">  </span><span class="nt">containers</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span>- <span class="nt">name</span><span class="p">:</span><span class="w"> </span><span class="l">psql</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span><span class="nt">image</span><span class="p">:</span><span class="w"> </span><span class="l">postgres:17</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span><span class="nt">command</span><span class="p">:</span><span class="w"> </span><span class="p">[</span><span class="s2">&#34;sleep&#34;</span><span class="p">,</span><span class="w"> </span><span class="s2">&#34;infinity&#34;</span><span class="p">]</span></span></span></code></pre></div>
</div>
<div class="code-block">
  <div class="code-block-header">
    <span class="code-block-lang">shell</span>
    <button type="button" class="code-copy" aria-label="Copy code">
      <span class="code-copy-icon"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><rect x="9" y="9" width="13" height="13" rx="2" ry="2"/><path d="M5 15H4a2 2 0 01-2-2V4a2 2 0 012-2h9a2 2 0 012 2v1"/></svg>
</span>
      <span class="code-copy-icon code-copy-icon-check"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><polyline points="20 6 9 17 4 12"/></svg>
</span>
      <span class="code-copy-label">Copy</span>
    </button>
  </div>
  <div class="highlight"><pre tabindex="0" class="chroma"><code class="language-shell" data-lang="shell"><span class="line"><span class="cl">❯ kubectl <span class="nb">exec</span> -it psql-client -- bash
</span></span><span class="line"><span class="cl">root@psql-client:/# psql <span class="s2">&#34;host=postgres-cluster-rw.postgres.svc port=5432 dbname=demo user=demo&#34;</span>
</span></span><span class="line"><span class="cl">Password <span class="k">for</span> user demo:
</span></span><span class="line"><span class="cl">psql <span class="o">(</span>17.2<span class="o">)</span>
</span></span><span class="line"><span class="cl">Type <span class="s2">&#34;help&#34;</span> <span class="k">for</span> help.
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="nv">demo</span><span class="o">=</span>&gt; <span class="se">\d</span>t
</span></span><span class="line"><span class="cl">Did not find any relations.
</span></span><span class="line"><span class="cl"><span class="nv">demo</span><span class="o">=</span>&gt; <span class="se">\q</span></span></span></code></pre></div>
</div>
<h2 id="scheduled-backups">Scheduled Backups</h2>
<p>Backups go through the Barman Cloud plugin to S3, on a daily schedule with a
30-day retention window:</p>
<div class="code-block">
  <div class="code-block-header">
    <span class="code-block-lang">yaml</span>
    <button type="button" class="code-copy" aria-label="Copy code">
      <span class="code-copy-icon"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><rect x="9" y="9" width="13" height="13" rx="2" ry="2"/><path d="M5 15H4a2 2 0 01-2-2V4a2 2 0 012-2h9a2 2 0 012 2v1"/></svg>
</span>
      <span class="code-copy-icon code-copy-icon-check"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><polyline points="20 6 9 17 4 12"/></svg>
</span>
      <span class="code-copy-label">Copy</span>
    </button>
  </div>
  <div class="highlight"><pre tabindex="0" class="chroma"><code class="language-yaml" data-lang="yaml"><span class="line"><span class="cl"><span class="w">  </span><span class="nt">backups</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">enabled</span><span class="p">:</span><span class="w"> </span><span class="kc">true</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">method</span><span class="p">:</span><span class="w"> </span><span class="l">plugin</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">pluginConfiguration</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span><span class="nt">name</span><span class="p">:</span><span class="w"> </span><span class="l">barman-cloud.cloudnative-pg.io</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">provider</span><span class="p">:</span><span class="w"> </span><span class="l">s3</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">s3</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span><span class="nt">region</span><span class="p">:</span><span class="w"> </span><span class="l">eu-central-1</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span><span class="nt">bucket</span><span class="p">:</span><span class="w"> </span><span class="l">my-pg-backups</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span><span class="nt">path</span><span class="p">:</span><span class="w"> </span><span class="s2">&#34;/shared&#34;</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span><span class="nt">accessKey</span><span class="p">:</span><span class="w"> </span><span class="s2">&#34;&lt;redacted&gt;&#34;</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span><span class="nt">secretKey</span><span class="p">:</span><span class="w"> </span><span class="s2">&#34;&lt;redacted&gt;&#34;</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">scheduledBackups</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span>- <span class="nt">name</span><span class="p">:</span><span class="w"> </span><span class="l">daily-backup</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">        </span><span class="nt">schedule</span><span class="p">:</span><span class="w"> </span><span class="s2">&#34;0 0 0 * * *&#34;</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">        </span><span class="nt">backupOwnerReference</span><span class="p">:</span><span class="w"> </span><span class="l">self</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">        </span><span class="nt">method</span><span class="p">:</span><span class="w"> </span><span class="l">plugin</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">        </span><span class="nt">pluginConfiguration</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">          </span><span class="nt">name</span><span class="p">:</span><span class="w"> </span><span class="l">barman-cloud.cloudnative-pg.io</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">retentionPolicy</span><span class="p">:</span><span class="w"> </span><span class="s2">&#34;30d&#34;</span></span></span></code></pre></div>
</div>
<p>Those keys sit plaintext in the same values file as everything else in this
homelab config - fine for a private repo, not something I&rsquo;d do anywhere
more exposed. I can also trigger a one-off backup outside the schedule with
a <code>Backup</code> object:</p>
<div class="code-block">
  <div class="code-block-header">
    <span class="code-block-lang">shell</span>
    <button type="button" class="code-copy" aria-label="Copy code">
      <span class="code-copy-icon"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><rect x="9" y="9" width="13" height="13" rx="2" ry="2"/><path d="M5 15H4a2 2 0 01-2-2V4a2 2 0 012-2h9a2 2 0 012 2v1"/></svg>
</span>
      <span class="code-copy-icon code-copy-icon-check"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><polyline points="20 6 9 17 4 12"/></svg>
</span>
      <span class="code-copy-label">Copy</span>
    </button>
  </div>
  <div class="highlight"><pre tabindex="0" class="chroma"><code class="language-shell" data-lang="shell"><span class="line"><span class="cl">❯ kubectl apply -f - <span class="s">&lt;&lt;EOF
</span></span></span><span class="line"><span class="cl"><span class="s">apiVersion: postgresql.cnpg.io/v1
</span></span></span><span class="line"><span class="cl"><span class="s">kind: Backup
</span></span></span><span class="line"><span class="cl"><span class="s">metadata:
</span></span></span><span class="line"><span class="cl"><span class="s">  name: postgres-manual-backup
</span></span></span><span class="line"><span class="cl"><span class="s">  namespace: postgres
</span></span></span><span class="line"><span class="cl"><span class="s">spec:
</span></span></span><span class="line"><span class="cl"><span class="s">  cluster:
</span></span></span><span class="line"><span class="cl"><span class="s">    name: postgres-cluster
</span></span></span><span class="line"><span class="cl"><span class="s">  method: plugin
</span></span></span><span class="line"><span class="cl"><span class="s">  pluginConfiguration:
</span></span></span><span class="line"><span class="cl"><span class="s">    name: barman-cloud.cloudnative-pg.io
</span></span></span><span class="line"><span class="cl"><span class="s">EOF</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl">❯ kubectl get backup postgres-manual-backup -n postgres
</span></span><span class="line"><span class="cl">NAME                     AGE   CLUSTER            METHOD   PHASE       ERROR
</span></span><span class="line"><span class="cl">postgres-manual-backup   14s   postgres-cluster    plugin   completed</span></span></code></pre></div>
</div>
<p>I haven&rsquo;t had to pull the recovery lever on this cluster for real yet.
Restoring is a separate <code>Cluster</code> with <code>mode: recovery</code> (instead of
<code>standalone</code>) pointed at the same S3 path - CNPG replays the base backup
plus WAL and comes back up as a new cluster, rather than overwriting the
one that&rsquo;s still running.</p>
<h2 id="conclusion">Conclusion</h2>
<p>One Helm release gets me an operator-managed Postgres cluster, replicated
storage underneath it courtesy of Ceph, and backups leaving the cluster
every night without me doing anything. It&rsquo;s a fresh cluster and I haven&rsquo;t
tested a disaster-recovery restore against it yet - that&rsquo;s the next thing
on my list, not something I&rsquo;d claim works until I&rsquo;ve actually broken it on
purpose.
<a href="https://omegion.dev/2024/09/talos-os-exposing-a-homelab-service-with-cloudflare-tunnel/">Next post</a>
:
this cluster is only reachable from inside the LAN, so I&rsquo;ll expose an app
in front of it to the internet through Cloudflare.</p>
]]></content:encoded></item><item><title>Talos OS: Persistent Volumes with Rook Ceph</title><link>https://omegion.dev/2023/03/talos-os-persistent-volumes-with-rook-ceph/</link><pubDate>Tue, 21 Mar 2023 00:00:00 +0000</pubDate><guid>https://omegion.dev/2023/03/talos-os-persistent-volumes-with-rook-ceph/</guid><description>Adding replicated block storage to a 3-node Talos Kubernetes cluster with Rook Ceph, ready for a database in the next post.</description><content:encoded><![CDATA[<h2 id="introduction">Introduction</h2>
<p>In the <a href="https://omegion.dev/2023/02/talos-os-setup-a-3-node-homelab-kubernetes-cluster/">previous post</a>
 I got a
3-node Talos cluster running, but it has no storage - anything that writes
to disk loses everything the moment its pod moves. The reason I care is
Postgres, which is coming in the next post, so this one is about getting a
real, replicated <code>StorageClass</code> in place first: Rook Ceph, spread across all
three nodes.</p>
<h2 id="prerequisites">Prerequisites</h2>
<ol>
<li>The 3-node Talos cluster from the
<a href="https://omegion.dev/2023/02/talos-os-setup-a-3-node-homelab-kubernetes-cluster/">previous post</a>
.</li>
<li><a href="https://helm.sh/docs/intro/install/" target="_blank" rel="noopener noreferrer">Helm 3</a>
.</li>
<li>A spare, unpartitioned chunk of disk on each node for
<a href="https://ceph.io/" target="_blank" rel="noopener noreferrer">Ceph</a>
 to use.</li>
</ol>
<h2 id="what-ceph-and-rook-actually-do">What Ceph and Rook Actually Do</h2>
<p>Without something like this, a <code>PersistentVolumeClaim</code> backed by local disk
is stuck on whichever node created it - if the pod gets rescheduled to
another node, its data doesn&rsquo;t come with it. <a href="https://ceph.io/" target="_blank" rel="noopener noreferrer">Ceph</a>
 pools
the disks across all three nodes into one distributed storage cluster and
replicates data between them, so a volume isn&rsquo;t tied to a single node&rsquo;s
disk anymore - a pod can land on any of the three and still mount the same
volume, with Ceph handling where the actual replicas live and keeping them
in sync.</p>
<p><a href="https://rook.io/" target="_blank" rel="noopener noreferrer">Rook</a>
 is what makes running Ceph itself not miserable:
it&rsquo;s a Kubernetes operator that deploys and manages the Ceph cluster -
mons, mgrs, OSDs, the CSI driver - from a handful of CRDs, and reacts to
things like a node going down by rebalancing data instead of me doing it by
hand.</p>
<pre class="mermaid" id="mermaid-0"></pre>
<script>(window.__mermaidSources ||= {})["mermaid-0"] = "flowchart TD\n  Pod[\"Pod - any node\"] --\u003e PVC[\"PVC - ceph-rbd\"]\n\n  subgraph Ceph[\"Ceph - replicated x3\"]\n    OSD1[\"OSD - node1\"]\n    OSD2[\"OSD - node2\"]\n    OSD3[\"OSD - node3\"]\n  end\n\n  PVC --\u003e OSD1\n  PVC --\u003e OSD2\n  PVC --\u003e OSD3";</script>

<h2 id="giving-talos-a-raw-partition-for-ceph">Giving Talos a Raw Partition for Ceph</h2>
<p>Each node only has one NVMe drive, so instead of a second physical disk I
had Talos carve the drive into two managed volumes: the normal <code>EPHEMERAL</code>
volume for the OS, and a raw volume for Ceph to claim as a block device.</p>
<div class="code-block">
  <div class="code-block-header">
    <span class="code-block-lang">yaml</span>
    <button type="button" class="code-copy" aria-label="Copy code">
      <span class="code-copy-icon"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><rect x="9" y="9" width="13" height="13" rx="2" ry="2"/><path d="M5 15H4a2 2 0 01-2-2V4a2 2 0 012-2h9a2 2 0 012 2v1"/></svg>
</span>
      <span class="code-copy-icon code-copy-icon-check"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><polyline points="20 6 9 17 4 12"/></svg>
</span>
      <span class="code-copy-label">Copy</span>
    </button>
  </div>
  <div class="highlight"><pre tabindex="0" class="chroma"><code class="language-yaml" data-lang="yaml"><span class="line"><span class="cl"><span class="nt">apiVersion</span><span class="p">:</span><span class="w"> </span><span class="l">v1alpha1</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="nt">kind</span><span class="p">:</span><span class="w"> </span><span class="l">VolumeConfig</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="nt">name</span><span class="p">:</span><span class="w"> </span><span class="l">EPHEMERAL</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="nt">provisioning</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">  </span><span class="nt">diskSelector</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">match</span><span class="p">:</span><span class="w"> </span><span class="l">disk.transport == &#39;nvme&#39;</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">  </span><span class="nt">maxSize</span><span class="p">:</span><span class="w"> </span><span class="l">100GiB</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="nn">---</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="nt">apiVersion</span><span class="p">:</span><span class="w"> </span><span class="l">v1alpha1</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="nt">kind</span><span class="p">:</span><span class="w"> </span><span class="l">RawVolumeConfig</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="nt">name</span><span class="p">:</span><span class="w"> </span><span class="l">osd-data</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="nt">provisioning</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">  </span><span class="nt">diskSelector</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">match</span><span class="p">:</span><span class="w"> </span><span class="l">disk.transport == &#39;nvme&#39;</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">  </span><span class="nt">minSize</span><span class="p">:</span><span class="w"> </span><span class="l">10GiB</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">  </span><span class="nt">grow</span><span class="p">:</span><span class="w"> </span><span class="kc">true</span></span></span></code></pre></div>
</div>
<p><code>EPHEMERAL</code> is where container images, pod logs, and etcd actually live -
the normal day-to-day disk usage of running Kubernetes on the node. I
capped it at 100GiB on purpose: left unbounded, that&rsquo;s the volume that
grows quietly over time as images pile up and logs accumulate, and I don&rsquo;t
want it eating into the space I need for Ceph. <code>osd-data</code> gets a 10GiB
floor and <code>grow: true</code> to claim whatever&rsquo;s left on the disk beyond that
cap.</p>
<p>Applying that config (same <code>talosctl apply-config</code> flow as part 1) makes
Talos create the partition and label it <code>r-osd-data</code>, which is what I point
Rook at below.</p>
<h2 id="installing-rook-ceph">Installing Rook Ceph</h2>
<p>I install the upstream <code>rook-ceph</code> and <code>rook-ceph-cluster</code> charts with a
values file. The important parts:</p>
<div class="code-block">
  <div class="code-block-header">
    <span class="code-block-lang">yaml</span>
    <button type="button" class="code-copy" aria-label="Copy code">
      <span class="code-copy-icon"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><rect x="9" y="9" width="13" height="13" rx="2" ry="2"/><path d="M5 15H4a2 2 0 01-2-2V4a2 2 0 012-2h9a2 2 0 012 2v1"/></svg>
</span>
      <span class="code-copy-icon code-copy-icon-check"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><polyline points="20 6 9 17 4 12"/></svg>
</span>
      <span class="code-copy-label">Copy</span>
    </button>
  </div>
  <div class="highlight"><pre tabindex="0" class="chroma"><code class="language-yaml" data-lang="yaml"><span class="line"><span class="cl"><span class="nt">rook-ceph-cluster</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">  </span><span class="nt">toolbox</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">enabled</span><span class="p">:</span><span class="w"> </span><span class="kc">true</span><span class="w">   </span><span class="c"># for `ceph status` debugging</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">  </span><span class="nt">cephClusterSpec</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">mon</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span><span class="nt">count</span><span class="p">:</span><span class="w"> </span><span class="m">3</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span><span class="nt">allowMultiplePerNode</span><span class="p">:</span><span class="w"> </span><span class="kc">false</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">mgr</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span><span class="nt">count</span><span class="p">:</span><span class="w"> </span><span class="m">2</span><span class="w">   </span><span class="c"># active/standby</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">dashboard</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span><span class="nt">enabled</span><span class="p">:</span><span class="w"> </span><span class="kc">true</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">storage</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span><span class="nt">useAllNodes</span><span class="p">:</span><span class="w"> </span><span class="kc">false</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span><span class="nt">useAllDevices</span><span class="p">:</span><span class="w"> </span><span class="kc">false</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span><span class="nt">nodes</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">        </span>- <span class="nt">name</span><span class="p">:</span><span class="w"> </span><span class="s2">&#34;node1&#34;</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">          </span><span class="nt">devices</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">            </span>- <span class="nt">fullpath</span><span class="p">:</span><span class="w"> </span><span class="l">/dev/disk/by-partlabel/r-osd-data</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">        </span>- <span class="nt">name</span><span class="p">:</span><span class="w"> </span><span class="s2">&#34;node2&#34;</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">          </span><span class="nt">devices</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">            </span>- <span class="nt">fullpath</span><span class="p">:</span><span class="w"> </span><span class="l">/dev/disk/by-partlabel/r-osd-data</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">        </span>- <span class="nt">name</span><span class="p">:</span><span class="w"> </span><span class="s2">&#34;node3&#34;</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">          </span><span class="nt">devices</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">            </span>- <span class="nt">fullpath</span><span class="p">:</span><span class="w"> </span><span class="l">/dev/disk/by-partlabel/r-osd-data</span></span></span></code></pre></div>
</div>
<div class="code-block">
  <div class="code-block-header">
    <span class="code-block-lang">shell</span>
    <button type="button" class="code-copy" aria-label="Copy code">
      <span class="code-copy-icon"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><rect x="9" y="9" width="13" height="13" rx="2" ry="2"/><path d="M5 15H4a2 2 0 01-2-2V4a2 2 0 012-2h9a2 2 0 012 2v1"/></svg>
</span>
      <span class="code-copy-icon code-copy-icon-check"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><polyline points="20 6 9 17 4 12"/></svg>
</span>
      <span class="code-copy-label">Copy</span>
    </button>
  </div>
  <div class="highlight"><pre tabindex="0" class="chroma"><code class="language-shell" data-lang="shell"><span class="line"><span class="cl">❯ helm repo add rook-release https://charts.rook.io/release
</span></span><span class="line"><span class="cl">❯ helm repo update
</span></span><span class="line"><span class="cl">❯ helm install rook-ceph rook-release/rook-ceph -n rook-ceph --create-namespace
</span></span><span class="line"><span class="cl">❯ helm install rook-ceph-cluster rook-release/rook-ceph-cluster -n rook-ceph -f values.yaml</span></span></code></pre></div>
</div>
<p>The operator (<code>rook-ceph</code>) and the cluster (<code>rook-ceph-cluster</code>) are two
separate chart installs into the same namespace - the operator first, then
the cluster chart with the values above, which is what actually stands up
the mons, mgrs, and OSDs.</p>
<p>3 nodes means 3 mons, which is the smallest quorum that tolerates one mon
going down. <code>useAllDevices: false</code> plus an explicit <code>nodes:</code> list matters
here - without it, Rook will happily try to claim any unformatted disk it
finds, including the wrong one.</p>
<h2 id="replication-across-all-three-nodes">Replication Across All Three Nodes</h2>
<div class="code-block">
  <div class="code-block-header">
    <span class="code-block-lang">yaml</span>
    <button type="button" class="code-copy" aria-label="Copy code">
      <span class="code-copy-icon"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><rect x="9" y="9" width="13" height="13" rx="2" ry="2"/><path d="M5 15H4a2 2 0 01-2-2V4a2 2 0 012-2h9a2 2 0 012 2v1"/></svg>
</span>
      <span class="code-copy-icon code-copy-icon-check"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><polyline points="20 6 9 17 4 12"/></svg>
</span>
      <span class="code-copy-label">Copy</span>
    </button>
  </div>
  <div class="highlight"><pre tabindex="0" class="chroma"><code class="language-yaml" data-lang="yaml"><span class="line"><span class="cl"><span class="w">  </span><span class="nt">cephBlockPools</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span>- <span class="nt">name</span><span class="p">:</span><span class="w"> </span><span class="l">replicapool</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span><span class="nt">spec</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">        </span><span class="nt">failureDomain</span><span class="p">:</span><span class="w"> </span><span class="l">host</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">        </span><span class="nt">replicated</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">          </span><span class="nt">size</span><span class="p">:</span><span class="w"> </span><span class="m">3</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">          </span><span class="nt">requireSafeReplicaSize</span><span class="p">:</span><span class="w"> </span><span class="kc">true</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span><span class="nt">storageClass</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">        </span><span class="nt">enabled</span><span class="p">:</span><span class="w"> </span><span class="kc">true</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">        </span><span class="nt">name</span><span class="p">:</span><span class="w"> </span><span class="l">ceph-rbd</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">        </span><span class="nt">isDefault</span><span class="p">:</span><span class="w"> </span><span class="kc">true</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">        </span><span class="nt">reclaimPolicy</span><span class="p">:</span><span class="w"> </span><span class="l">Delete</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">        </span><span class="nt">allowVolumeExpansion</span><span class="p">:</span><span class="w"> </span><span class="kc">true</span></span></span></code></pre></div>
</div>
<p><code>failureDomain: host</code> plus <code>size: 3</code> means every object gets a full copy on
each of the three nodes - I can lose one node entirely and still have every
piece of data available twice over. <code>ceph-rbd</code> becomes the default
<code>StorageClass</code>, so anything that creates a <code>PersistentVolumeClaim</code> without
specifying one lands here automatically.</p>
<p>I also explicitly turned off the two other pool types the chart wants to
create by default:</p>
<div class="code-block">
  <div class="code-block-header">
    <span class="code-block-lang">yaml</span>
    <button type="button" class="code-copy" aria-label="Copy code">
      <span class="code-copy-icon"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><rect x="9" y="9" width="13" height="13" rx="2" ry="2"/><path d="M5 15H4a2 2 0 01-2-2V4a2 2 0 012-2h9a2 2 0 012 2v1"/></svg>
</span>
      <span class="code-copy-icon code-copy-icon-check"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><polyline points="20 6 9 17 4 12"/></svg>
</span>
      <span class="code-copy-label">Copy</span>
    </button>
  </div>
  <div class="highlight"><pre tabindex="0" class="chroma"><code class="language-yaml" data-lang="yaml"><span class="line"><span class="cl"><span class="w">  </span><span class="nt">cephFileSystems</span><span class="p">:</span><span class="w"> </span><span class="p">[]</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">  </span><span class="nt">cephObjectStores</span><span class="p">:</span><span class="w"> </span><span class="p">[]</span></span></span></code></pre></div>
</div>
<p>Both <code>CephFilesystem</code> and <code>CephObjectStore</code> default to erasure-coded pools
that need 3 failure domains of their own on top of the block pool. I only
need RBD block storage for a database, so leaving those enabled would just
be wasted capacity on a 3-node cluster.</p>
<h2 id="testing-it-with-a-throwaway-pod">Testing It With a Throwaway Pod</h2>
<p>Before trusting it with a database, I wanted to prove a pod&rsquo;s data actually
survives a restart:</p>
<div class="code-block">
  <div class="code-block-header">
    <span class="code-block-lang">yaml</span>
    <button type="button" class="code-copy" aria-label="Copy code">
      <span class="code-copy-icon"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><rect x="9" y="9" width="13" height="13" rx="2" ry="2"/><path d="M5 15H4a2 2 0 01-2-2V4a2 2 0 012-2h9a2 2 0 012 2v1"/></svg>
</span>
      <span class="code-copy-icon code-copy-icon-check"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><polyline points="20 6 9 17 4 12"/></svg>
</span>
      <span class="code-copy-label">Copy</span>
    </button>
  </div>
  <div class="highlight"><pre tabindex="0" class="chroma"><code class="language-yaml" data-lang="yaml"><span class="line"><span class="cl"><span class="nt">apiVersion</span><span class="p">:</span><span class="w"> </span><span class="l">v1</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="nt">kind</span><span class="p">:</span><span class="w"> </span><span class="l">PersistentVolumeClaim</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="nt">metadata</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">  </span><span class="nt">name</span><span class="p">:</span><span class="w"> </span><span class="l">ceph-test-pvc</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="nt">spec</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">  </span><span class="nt">accessModes</span><span class="p">:</span><span class="w"> </span><span class="p">[</span><span class="s2">&#34;ReadWriteOnce&#34;</span><span class="p">]</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">  </span><span class="nt">storageClassName</span><span class="p">:</span><span class="w"> </span><span class="l">ceph-rbd</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">  </span><span class="nt">resources</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">requests</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span><span class="nt">storage</span><span class="p">:</span><span class="w"> </span><span class="l">1Gi</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="nn">---</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="nt">apiVersion</span><span class="p">:</span><span class="w"> </span><span class="l">v1</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="nt">kind</span><span class="p">:</span><span class="w"> </span><span class="l">Pod</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="nt">metadata</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">  </span><span class="nt">name</span><span class="p">:</span><span class="w"> </span><span class="l">ceph-test-pod</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="nt">spec</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">  </span><span class="nt">containers</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span>- <span class="nt">name</span><span class="p">:</span><span class="w"> </span><span class="l">test</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span><span class="nt">image</span><span class="p">:</span><span class="w"> </span><span class="l">busybox</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span><span class="nt">command</span><span class="p">:</span><span class="w"> </span><span class="p">[</span><span class="s2">&#34;sh&#34;</span><span class="p">,</span><span class="w"> </span><span class="s2">&#34;-c&#34;</span><span class="p">,</span><span class="w"> </span><span class="s2">&#34;echo hello from ceph &gt; /data/hello.txt &amp;&amp; sleep 3600&#34;</span><span class="p">]</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span><span class="nt">volumeMounts</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">        </span>- <span class="nt">name</span><span class="p">:</span><span class="w"> </span><span class="l">data</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">          </span><span class="nt">mountPath</span><span class="p">:</span><span class="w"> </span><span class="l">/data</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">  </span><span class="nt">volumes</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span>- <span class="nt">name</span><span class="p">:</span><span class="w"> </span><span class="l">data</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span><span class="nt">persistentVolumeClaim</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">        </span><span class="nt">claimName</span><span class="p">:</span><span class="w"> </span><span class="l">ceph-test-pvc</span></span></span></code></pre></div>
</div>
<div class="code-block">
  <div class="code-block-header">
    <span class="code-block-lang">shell</span>
    <button type="button" class="code-copy" aria-label="Copy code">
      <span class="code-copy-icon"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><rect x="9" y="9" width="13" height="13" rx="2" ry="2"/><path d="M5 15H4a2 2 0 01-2-2V4a2 2 0 012-2h9a2 2 0 012 2v1"/></svg>
</span>
      <span class="code-copy-icon code-copy-icon-check"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><polyline points="20 6 9 17 4 12"/></svg>
</span>
      <span class="code-copy-label">Copy</span>
    </button>
  </div>
  <div class="highlight"><pre tabindex="0" class="chroma"><code class="language-shell" data-lang="shell"><span class="line"><span class="cl">❯ kubectl apply -f ceph-test.yaml
</span></span><span class="line"><span class="cl">❯ kubectl get pvc ceph-test-pvc
</span></span><span class="line"><span class="cl">NAME            STATUS   VOLUME     CAPACITY   ACCESS MODES   STORAGECLASS
</span></span><span class="line"><span class="cl">ceph-test-pvc   Bound    pvc-a1b2   1Gi        RWO            ceph-rbd
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl">❯ kubectl delete pod ceph-test-pod
</span></span><span class="line"><span class="cl">❯ kubectl apply -f ceph-test.yaml
</span></span><span class="line"><span class="cl">❯ kubectl <span class="nb">exec</span> ceph-test-pod -- cat /data/hello.txt
</span></span><span class="line"><span class="cl">hello from ceph</span></span></code></pre></div>
</div>
<p>The pod got deleted and recreated, the PVC stayed bound, and the file was
still there. That&rsquo;s the whole point.</p>
<h2 id="conclusion">Conclusion</h2>
<p>Three Ceph mons, three-way replicated block storage, and a default
<code>StorageClass</code> that survives a node dropping out. It&rsquo;s not fast storage -
network-replicated block storage on consumer NVMe over a home LAN never
will be - but it&rsquo;s durable, which is what a database actually needs.
<a href="https://omegion.dev/2023/11/talos-os-running-postgres-with-cloudnativepg-on-ceph/">Next post</a>
:
putting a real Postgres cluster on top of it.</p>
]]></content:encoded></item><item><title>Talos OS Setup: A 3-Node Homelab Kubernetes Cluster</title><link>https://omegion.dev/2023/02/talos-os-setup-a-3-node-homelab-kubernetes-cluster/</link><pubDate>Tue, 14 Feb 2023 00:00:00 +0000</pubDate><guid>https://omegion.dev/2023/02/talos-os-setup-a-3-node-homelab-kubernetes-cluster/</guid><description>Setting up a 3-node, all-control-plane Talos Linux Kubernetes cluster on mini PCs for my homelab.</description><content:encoded><![CDATA[<h2 id="introduction">Introduction</h2>
<p>I was running a handful of services on a managed Kubernetes cluster on
Hetzner. It worked fine, but the hardware pricing kept climbing and I was
paying every month for compute that idled most of the time. I ended up with
three small mini PCs (16-32GB RAM each) sitting on my desk instead, and
decided to move those services onto a real cluster of my own: something
that survives a node dying, that I don&rsquo;t have to SSH into to patch, and
that I don&rsquo;t have to babysit. Talos Linux fit that better than a regular
distro plus kubeadm - no shell, no package manager, the whole machine is
configured from one YAML file and managed over an API. This post is the
base cluster. Storage, a database, and exposing something to the internet
come in later posts.</p>
<h2 id="prerequisites">Prerequisites</h2>
<ol>
<li>Three (or more) amd64 machines that can run Talos Linux. I used three
mini PCs.</li>
<li>Each machine booted into Talos maintenance mode (a Talos ISO on a USB
stick works fine).</li>
<li><code>talosctl</code> installed locally.</li>
<li>A LAN segment where you can hand out static IPs to the nodes.</li>
</ol>
<h2 id="why-all-three-nodes-are-control-plane">Why All Three Nodes Are Control Plane</h2>
<p>Three nodes is the minimum for an etcd quorum that survives one node going
down. I didn&rsquo;t have a fourth machine to dedicate as a worker, so instead of
one control-plane node and two workers, all three run as control plane with
scheduling enabled on them:</p>
<div class="code-block">
  <div class="code-block-header">
    <span class="code-block-lang">yaml</span>
    <button type="button" class="code-copy" aria-label="Copy code">
      <span class="code-copy-icon"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><rect x="9" y="9" width="13" height="13" rx="2" ry="2"/><path d="M5 15H4a2 2 0 01-2-2V4a2 2 0 012-2h9a2 2 0 012 2v1"/></svg>
</span>
      <span class="code-copy-icon code-copy-icon-check"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><polyline points="20 6 9 17 4 12"/></svg>
</span>
      <span class="code-copy-label">Copy</span>
    </button>
  </div>
  <div class="highlight"><pre tabindex="0" class="chroma"><code class="language-yaml" data-lang="yaml"><span class="line"><span class="cl"><span class="nt">cluster</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">  </span><span class="nt">allowSchedulingOnControlPlanes</span><span class="p">:</span><span class="w"> </span><span class="kc">true</span></span></span></code></pre></div>
</div>
<p>That gets me HA etcd and three schedulable nodes, at the cost of workloads
sharing the box with the control plane components. For a homelab that
tradeoff is fine.</p>
<h2 id="generating-the-base-config">Generating the Base Config</h2>
<div class="code-block">
  <div class="code-block-header">
    <span class="code-block-lang">shell</span>
    <button type="button" class="code-copy" aria-label="Copy code">
      <span class="code-copy-icon"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><rect x="9" y="9" width="13" height="13" rx="2" ry="2"/><path d="M5 15H4a2 2 0 01-2-2V4a2 2 0 012-2h9a2 2 0 012 2v1"/></svg>
</span>
      <span class="code-copy-icon code-copy-icon-check"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><polyline points="20 6 9 17 4 12"/></svg>
</span>
      <span class="code-copy-label">Copy</span>
    </button>
  </div>
  <div class="highlight"><pre tabindex="0" class="chroma"><code class="language-shell" data-lang="shell"><span class="line"><span class="cl">talosctl gen config homelab https://192.168.1.10:6443 --output-dir configs</span></span></code></pre></div>
</div>
<p>This writes <code>controlplane.yaml</code>, <code>worker.yaml</code>, and <code>talosconfig</code> into
<code>configs/</code>. Since every node here is a control-plane node, I only need
<code>controlplane.yaml</code> - copied once per node and edited for hostname and IP.</p>
<h2 id="per-node-network-config">Per-Node Network Config</h2>
<p>Each node gets a static IP instead of DHCP, plus its hostname:</p>
<div class="code-block">
  <div class="code-block-header">
    <span class="code-block-lang">yaml</span>
    <button type="button" class="code-copy" aria-label="Copy code">
      <span class="code-copy-icon"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><rect x="9" y="9" width="13" height="13" rx="2" ry="2"/><path d="M5 15H4a2 2 0 01-2-2V4a2 2 0 012-2h9a2 2 0 012 2v1"/></svg>
</span>
      <span class="code-copy-icon code-copy-icon-check"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><polyline points="20 6 9 17 4 12"/></svg>
</span>
      <span class="code-copy-label">Copy</span>
    </button>
  </div>
  <div class="highlight"><pre tabindex="0" class="chroma"><code class="language-yaml" data-lang="yaml"><span class="line"><span class="cl"><span class="nt">machine</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">  </span><span class="nt">network</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">hostname</span><span class="p">:</span><span class="w"> </span><span class="l">node1</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">interfaces</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span>- <span class="nt">interface</span><span class="p">:</span><span class="w"> </span><span class="l">eth0</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">        </span><span class="nt">dhcp</span><span class="p">:</span><span class="w"> </span><span class="kc">false</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">        </span><span class="nt">addresses</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">          </span>- <span class="m">192.168.1.10</span><span class="l">/24</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">        </span><span class="nt">routes</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">          </span>- <span class="nt">network</span><span class="p">:</span><span class="w"> </span><span class="m">0.0.0.0</span><span class="l">/0</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">            </span><span class="nt">gateway</span><span class="p">:</span><span class="w"> </span><span class="m">192.168.1.1</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">nameservers</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span>- <span class="m">192.168.1.1</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">  </span><span class="nt">install</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">disk</span><span class="p">:</span><span class="w"> </span><span class="l">/dev/nvme0n1</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">wipe</span><span class="p">:</span><span class="w"> </span><span class="kc">false</span></span></span></code></pre></div>
</div>
<p><code>node2</code> and <code>node3</code> are the same file with <code>192.168.1.11</code> / <code>192.168.1.12</code>
and the matching <code>hostname:</code> at the bottom.</p>
<p>Laid out, the network looks like this - all three nodes and my laptop share
one flat 192.168.1.0/24 LAN, <code>kubectl</code> only ever talks to node1 directly,
and the nodes talk to each other over etcd to keep quorum:</p>
<pre class="mermaid" id="mermaid-3"></pre>
<script>(window.__mermaidSources ||= {})["mermaid-3"] = "flowchart TD\n  Client[\"My laptop\"] --\u003e|\"kubectl :6443\"| Node1\n\n  subgraph LAN[\"Home LAN\"]\n    Node1[\"node1 192.168.1.10\"]\n    Node2[\"node2 192.168.1.11\"]\n    Node3[\"node3 192.168.1.12\"]\n  end\n\n  Node1 -.-\u003e|etcd| Node2\n  Node2 -.-\u003e|etcd| Node3";</script>

<h2 id="cni-flannel-with-networkpolicy-actually-enforced">CNI: Flannel, With NetworkPolicy Actually Enforced</h2>
<p>Talos ships Flannel by default. As of Talos 1.13, Flannel can enforce
standard Kubernetes <code>NetworkPolicy</code> objects natively, which meant I didn&rsquo;t
need to bolt on Calico or Cilium just to get policy enforcement:</p>
<div class="code-block">
  <div class="code-block-header">
    <span class="code-block-lang">yaml</span>
    <button type="button" class="code-copy" aria-label="Copy code">
      <span class="code-copy-icon"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><rect x="9" y="9" width="13" height="13" rx="2" ry="2"/><path d="M5 15H4a2 2 0 01-2-2V4a2 2 0 012-2h9a2 2 0 012 2v1"/></svg>
</span>
      <span class="code-copy-icon code-copy-icon-check"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><polyline points="20 6 9 17 4 12"/></svg>
</span>
      <span class="code-copy-label">Copy</span>
    </button>
  </div>
  <div class="highlight"><pre tabindex="0" class="chroma"><code class="language-yaml" data-lang="yaml"><span class="line"><span class="cl"><span class="nt">cluster</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">  </span><span class="nt">network</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">cni</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span><span class="nt">name</span><span class="p">:</span><span class="w"> </span><span class="l">flannel</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span><span class="nt">flannel</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">        </span><span class="nt">kubeNetworkPoliciesEnabled</span><span class="p">:</span><span class="w"> </span><span class="kc">true</span></span></span></code></pre></div>
</div>
<p>Without that flag, <code>NetworkPolicy</code> resources are accepted by the API but
silently not enforced - worth knowing before you assume a policy is doing
anything.</p>
<h2 id="why-i-actually-need-networkpolicy-here">Why I Actually Need NetworkPolicy Here</h2>
<p>These three nodes sit on the same flat LAN as the rest of my house - no
VLAN, no physical separation between &ldquo;cluster&rdquo; and &ldquo;home network.&rdquo; By
default a pod can reach my NAS or router just as easily as it can reach
another pod. <code>NetworkPolicy</code> is the only thing stopping that.</p>
<p>The pattern is default-deny egress per namespace, plus an explicit
carve-out for anything that needs the internet:</p>
<div class="code-block">
  <div class="code-block-header">
    <span class="code-block-lang">yaml</span>
    <button type="button" class="code-copy" aria-label="Copy code">
      <span class="code-copy-icon"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><rect x="9" y="9" width="13" height="13" rx="2" ry="2"/><path d="M5 15H4a2 2 0 01-2-2V4a2 2 0 012-2h9a2 2 0 012 2v1"/></svg>
</span>
      <span class="code-copy-icon code-copy-icon-check"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><polyline points="20 6 9 17 4 12"/></svg>
</span>
      <span class="code-copy-label">Copy</span>
    </button>
  </div>
  <div class="highlight"><pre tabindex="0" class="chroma"><code class="language-yaml" data-lang="yaml"><span class="line"><span class="cl"><span class="nt">apiVersion</span><span class="p">:</span><span class="w"> </span><span class="l">networking.k8s.io/v1</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="nt">kind</span><span class="p">:</span><span class="w"> </span><span class="l">NetworkPolicy</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="nt">metadata</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">  </span><span class="nt">name</span><span class="p">:</span><span class="w"> </span><span class="l">postgres-egress</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="nt">spec</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">  </span><span class="nt">podSelector</span><span class="p">:</span><span class="w"> </span>{}<span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">  </span><span class="nt">policyTypes</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span>- <span class="l">Egress</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">  </span><span class="nt">egress</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span>- <span class="nt">to</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">        </span>- <span class="nt">ipBlock</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">            </span><span class="nt">cidr</span><span class="p">:</span><span class="w"> </span><span class="m">0.0.0.0</span><span class="l">/0</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">            </span><span class="nt">except</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">              </span>- <span class="m">192.168.1.0</span><span class="l">/24</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span><span class="nt">ports</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">        </span>- <span class="nt">protocol</span><span class="p">:</span><span class="w"> </span><span class="l">TCP</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">          </span><span class="nt">port</span><span class="p">:</span><span class="w"> </span><span class="m">443</span></span></span></code></pre></div>
</div>
<p>Allow HTTPS to anywhere, except the home LAN - a pod can reach S3, not my
NAS.</p>
<p>Worth being honest about what this is: not real isolation. No VLAN, no
firewall appliance, enforcement happens in Flannel on the same node the pod
runs on - a kernel or CNI bug bypasses it entirely. It stops the boring
case, a buggy app reaching somewhere on my LAN it shouldn&rsquo;t, which is good
enough for a homelab. Anywhere it actually mattered, physical or VLAN
segmentation would be step one, not a &ldquo;maybe later.&rdquo;</p>
<h2 id="applying-config-and-bootstrapping">Applying Config and Bootstrapping</h2>
<p>While a node is still in maintenance mode it&rsquo;s on a temporary DHCP address,
not the static one baked into its config yet, so the first apply targets
that temporary address with <code>--insecure</code>:</p>
<div class="code-block">
  <div class="code-block-header">
    <span class="code-block-lang">shell</span>
    <button type="button" class="code-copy" aria-label="Copy code">
      <span class="code-copy-icon"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><rect x="9" y="9" width="13" height="13" rx="2" ry="2"/><path d="M5 15H4a2 2 0 01-2-2V4a2 2 0 012-2h9a2 2 0 012 2v1"/></svg>
</span>
      <span class="code-copy-icon code-copy-icon-check"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><polyline points="20 6 9 17 4 12"/></svg>
</span>
      <span class="code-copy-label">Copy</span>
    </button>
  </div>
  <div class="highlight"><pre tabindex="0" class="chroma"><code class="language-shell" data-lang="shell"><span class="line"><span class="cl">❯ talosctl apply-config --insecure -n 192.168.1.110 --file configs/node1.yaml</span></span></code></pre></div>
</div>
<p>Repeat for node2 and node3 against their own temporary addresses. Once all
three have rebooted onto their real static IPs, bootstrap etcd on one of
them:</p>
<div class="code-block">
  <div class="code-block-header">
    <span class="code-block-lang">shell</span>
    <button type="button" class="code-copy" aria-label="Copy code">
      <span class="code-copy-icon"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><rect x="9" y="9" width="13" height="13" rx="2" ry="2"/><path d="M5 15H4a2 2 0 01-2-2V4a2 2 0 012-2h9a2 2 0 012 2v1"/></svg>
</span>
      <span class="code-copy-icon code-copy-icon-check"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><polyline points="20 6 9 17 4 12"/></svg>
</span>
      <span class="code-copy-label">Copy</span>
    </button>
  </div>
  <div class="highlight"><pre tabindex="0" class="chroma"><code class="language-shell" data-lang="shell"><span class="line"><span class="cl">❯ talosctl bootstrap -n 192.168.1.10</span></span></code></pre></div>
</div>
<p>Then pull a kubeconfig:</p>
<div class="code-block">
  <div class="code-block-header">
    <span class="code-block-lang">shell</span>
    <button type="button" class="code-copy" aria-label="Copy code">
      <span class="code-copy-icon"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><rect x="9" y="9" width="13" height="13" rx="2" ry="2"/><path d="M5 15H4a2 2 0 01-2-2V4a2 2 0 012-2h9a2 2 0 012 2v1"/></svg>
</span>
      <span class="code-copy-icon code-copy-icon-check"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><polyline points="20 6 9 17 4 12"/></svg>
</span>
      <span class="code-copy-label">Copy</span>
    </button>
  </div>
  <div class="highlight"><pre tabindex="0" class="chroma"><code class="language-shell" data-lang="shell"><span class="line"><span class="cl">❯ talosctl kubeconfig ~/.kube/homelab -n 192.168.1.10
</span></span><span class="line"><span class="cl">❯ <span class="nb">export</span> <span class="nv">KUBECONFIG</span><span class="o">=</span>~/.kube/homelab
</span></span><span class="line"><span class="cl">❯ kubectl get nodes
</span></span><span class="line"><span class="cl">NAME    STATUS   ROLES           AGE   VERSION
</span></span><span class="line"><span class="cl">node1   Ready    control-plane   3m    v1.36.3
</span></span><span class="line"><span class="cl">node2   Ready    control-plane   2m    v1.36.3
</span></span><span class="line"><span class="cl">node3   Ready    control-plane   1m    v1.36.3</span></span></code></pre></div>
</div>
<h2 id="no-vip-yet-just-quorum">No VIP Yet, Just Quorum</h2>
<p>Worth being honest about a gap here: <code>cluster.controlPlane.endpoint</code> points
at node1&rsquo;s static IP directly, not a floating VIP. What I have is etcd
quorum HA - the cluster tolerates one node going down - not endpoint HA. If
node1 itself is the one that goes down, <code>kubectl</code> pointed at
<code>192.168.1.10:6443</code> stops working until I repoint it at node2 or node3
manually, since all three run the API server. That&rsquo;s a real limitation I&rsquo;m
leaving for later - a keepalived VIP or an external load balancer would fix
it, but for a cluster with one client (me) it hasn&rsquo;t been worth the extra
moving part yet.</p>
<h2 id="conclusion">Conclusion</h2>
<p>Three nodes, one YAML file each, no SSH, no OS to patch by hand. The cluster
itself was the easy part - it doesn&rsquo;t do anything yet. Next up:
<a href="https://omegion.dev/2023/03/talos-os-persistent-volumes-with-rook-ceph/">persistent storage</a>
,
because a Postgres cluster is coming and it needs somewhere to put its data
that survives a node reboot.</p>
]]></content:encoded></item><item><title>Scrummy</title><link>https://omegion.dev/projects/scrummy/</link><pubDate>Thu, 01 Sep 2022 00:00:00 +0000</pubDate><guid>https://omegion.dev/projects/scrummy/</guid><description>Scrum sizing game for remote teams.</description><content:encoded><![CDATA[<p>Scrummy is a free, real-time planning poker tool for remote Scrum teams.
Create a room, share the link, and the team votes on story points
together — no accounts required. Rooms can be password-protected and pick
from a few card decks (Fibonacci, T-shirt sizes, binary).</p>
<p>It runs on a Cloudflare Durable Object per room, using the WebSocket
Hibernation API so an idle room holds no live compute between messages —
state lives in the Durable Object&rsquo;s SQLite storage and rehydrates on the
next message. Rooms clean themselves up after 30 days of total
inactivity.</p>
]]></content:encoded></item><item><title>Using Counter API for static websites</title><link>https://omegion.dev/2022/08/using-counter-api-for-static-websites/</link><pubDate>Tue, 09 Aug 2022 00:00:00 +0000</pubDate><guid>https://omegion.dev/2022/08/using-counter-api-for-static-websites/</guid><description>The Counter API is a free and simple-to-use API that allows developers to count things without the need for authentication.</description><content:encoded><![CDATA[<h2 id="introduction">Introduction</h2>
<p>When building a static website with <code>Vuepress</code>, <code>Hugo</code>, or <code>mkdocs</code>, it is not possible to add API tokens to authenticate requests. This is a security measure to prevent unauthorized access to your API and data. However, if you need to provide a service that does not require authentication.</p>
<p>These services allow you to deploy static websites and APIs without the need for authentication, making it easier to build and deploy your applications.</p>
<p>The <a href="https://counterapi.dev" target="_blank" rel="noopener noreferrer">Counter API</a>
 addresses this issue by offering a free API that does not require authentication to count things. This means that anyone can access the API and use it to count anything, without the need for any credentials or authentication.</p>
<p>This is a great solution for developers who want to build applications that require counting without the need for authentication. The <a href="https://counterapi.dev" target="_blank" rel="noopener noreferrer">Counter API</a>
 is a simple and easy-to-use API. Overall, the Counter API is a great solution for developers who want to build applications that require counting without the need for authentication.</p>
<h2 id="what-is-counter-api">What is Counter API?</h2>
<p>The Counter API is a simple and easy-to-use REST API that allows developers to store and retrieve count data in static applications without the need for a backend.</p>
<p>With the Counter API, you can easily keep track of user engagement, website traffic, and other important metrics without the need for a database or server.</p>
<p>The API is available in both JavaScript and Go programming languages, making it easy for developers to integrate it into their projects.</p>
<h2 id="how-to-use-it-with-javascript">How to use it with Javascript?</h2>
<p>The Counter API is a module that can be easily integrated into your JavaScript project without the need for API calls. Simply import the module and use it in your code to track and analyze website visitors.</p>
<p>The fastest way to get Counter API for your project, install it with <code>yarn</code> or <code>npm</code>.</p>
<p>To use it, open up your terminal in the desired directory and run the following command:</p>
<div class="code-block">
  <div class="code-block-header">
    <span class="code-block-lang">bash</span>
    <button type="button" class="code-copy" aria-label="Copy code">
      <span class="code-copy-icon"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><rect x="9" y="9" width="13" height="13" rx="2" ry="2"/><path d="M5 15H4a2 2 0 01-2-2V4a2 2 0 012-2h9a2 2 0 012 2v1"/></svg>
</span>
      <span class="code-copy-icon code-copy-icon-check"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><polyline points="20 6 9 17 4 12"/></svg>
</span>
      <span class="code-copy-label">Copy</span>
    </button>
  </div>
  <div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="cl">yarn add counterapi.dev</span></span></code></pre></div>
</div>
<h3 id="count-up">Count Up</h3>
<p>You can count up with a given name and the name of the counter is unique. This means that only one person with that name can count up or down at a time. It&rsquo;s important to note that anyone with the same name can also count up or down, so be sure to choose a unique name for your counter.</p>
<div class="code-block">
  <div class="code-block-header">
    <span class="code-block-lang">javascript</span>
    <button type="button" class="code-copy" aria-label="Copy code">
      <span class="code-copy-icon"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><rect x="9" y="9" width="13" height="13" rx="2" ry="2"/><path d="M5 15H4a2 2 0 01-2-2V4a2 2 0 012-2h9a2 2 0 012 2v1"/></svg>
</span>
      <span class="code-copy-icon code-copy-icon-check"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><polyline points="20 6 9 17 4 12"/></svg>
</span>
      <span class="code-copy-label">Copy</span>
    </button>
  </div>
  <div class="highlight"><pre tabindex="0" class="chroma"><code class="language-javascript" data-lang="javascript"><span class="line"><span class="cl"><span class="kr">import</span> <span class="p">{</span><span class="nx">CounterAPI</span><span class="p">}</span> <span class="nx">from</span> <span class="s2">&#34;counterapi&#34;</span><span class="p">;</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="kr">const</span> <span class="nx">counter</span> <span class="o">=</span> <span class="k">new</span> <span class="nx">CounterAPI</span><span class="p">();</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="nx">counter</span><span class="p">.</span><span class="nx">up</span><span class="p">(</span><span class="s2">&#34;test&#34;</span><span class="p">,</span> <span class="s2">&#34;test&#34;</span><span class="p">).</span><span class="nx">then</span><span class="p">((</span><span class="nx">res</span><span class="p">)</span> <span class="p">=&gt;</span> <span class="p">{</span>
</span></span><span class="line"><span class="cl">    <span class="nx">console</span><span class="p">.</span><span class="nx">log</span><span class="p">(</span><span class="nx">res</span><span class="p">)</span>
</span></span><span class="line"><span class="cl"><span class="p">})</span></span></span></code></pre></div>
</div>
<h2 id="how-to-use-it-with-golang">How to use it with Golang?</h2>
<p>The Counter API is a powerful REST API that allows developers to store and retrieve count data in a variety of applications. With the Counter API, you can easily keep track of user engagement, website traffic, and other important metrics without the need for a database or server.</p>
<p>The API is available in both Go and CLI programming languages, making it available for a wide range of platforms. The Go library provides a convenient way to integrate the API into your Go projects, while the CLI provides a simple way to manage your count data from the command line. Whether you&rsquo;re a developer looking to build a custom dashboard or a business owner looking to track their website traffic, the Counter API is the perfect solution for you.</p>
<h3 id="installation">Installation</h3>
<p>The fastest way to get Counter API for your project, install it with go itself.</p>
<p>To use it, open up your terminal and run the following command:</p>
<div class="code-block">
  <div class="code-block-header">
    <span class="code-block-lang">shell</span>
    <button type="button" class="code-copy" aria-label="Copy code">
      <span class="code-copy-icon"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><rect x="9" y="9" width="13" height="13" rx="2" ry="2"/><path d="M5 15H4a2 2 0 01-2-2V4a2 2 0 012-2h9a2 2 0 012 2v1"/></svg>
</span>
      <span class="code-copy-icon code-copy-icon-check"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><polyline points="20 6 9 17 4 12"/></svg>
</span>
      <span class="code-copy-label">Copy</span>
    </button>
  </div>
  <div class="highlight"><pre tabindex="0" class="chroma"><code class="language-shell" data-lang="shell"><span class="line"><span class="cl">go install github.com/counterapi/counter-go@latest</span></span></code></pre></div>
</div>
<h3 id="count-up-1">Count Up</h3>
<p>You can count up with a given name and the name of the counter is unique. This means that only one person with that name can count up or down at a time. It&rsquo;s important to note that anyone with the same name can also count up or down, so be sure to choose a unique name for your counter.</p>
<div class="code-block">
  <div class="code-block-header">
    <span class="code-block-lang">shell</span>
    <button type="button" class="code-copy" aria-label="Copy code">
      <span class="code-copy-icon"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><rect x="9" y="9" width="13" height="13" rx="2" ry="2"/><path d="M5 15H4a2 2 0 01-2-2V4a2 2 0 012-2h9a2 2 0 012 2v1"/></svg>
</span>
      <span class="code-copy-icon code-copy-icon-check"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><polyline points="20 6 9 17 4 12"/></svg>
</span>
      <span class="code-copy-label">Copy</span>
    </button>
  </div>
  <div class="highlight"><pre tabindex="0" class="chroma"><code class="language-shell" data-lang="shell"><span class="line"><span class="cl">❯ counter up --name MyCounter01 --namespace example.com               
</span></span><span class="line"><span class="cl"><span class="o">{</span>
</span></span><span class="line"><span class="cl">    <span class="s2">&#34;id&#34;</span>: 12,
</span></span><span class="line"><span class="cl">    <span class="s2">&#34;name&#34;</span>: <span class="s2">&#34;MyCounter01&#34;</span>,
</span></span><span class="line"><span class="cl">    <span class="s2">&#34;count&#34;</span>: 1,
</span></span><span class="line"><span class="cl">    <span class="s2">&#34;created_at&#34;</span>: <span class="s2">&#34;2021-03-28T21:19:09.450461117Z&#34;</span>,
</span></span><span class="line"><span class="cl">    <span class="s2">&#34;updated_at&#34;</span>: <span class="s2">&#34;2021-03-28T21:19:09.466029457Z&#34;</span>
</span></span><span class="line"><span class="cl"><span class="o">}</span></span></span></code></pre></div>
</div>
<h2 id="conclusion">Conclusion</h2>
<p>In conclusion, the <a href="https://counterapi.dev" target="_blank" rel="noopener noreferrer">Counter API</a>
 is a great solution for developers who want to build applications that require counting without the need for authentication. The API is available in both JavaScript and Go programming languages, making it easy for developers to integrate it into their projects.</p>
<p>The Counter API is a simple and easy-to-use API that allows developers to store and retrieve count data in static applications without the need for a backend. With the <a href="https://counterapi.dev" target="_blank" rel="noopener noreferrer">Counter API</a>
, you can easily keep track of user engagement, website traffic, and other important metrics without the need for a database or server. Overall, the Counter API is a great solution for developers who want to build applications that require counting without the need for authentication.</p>
]]></content:encoded></item><item><title>How to setup Redis cache for Gin Web Framework</title><link>https://omegion.dev/2022/05/how-to-setup-redis-cache-for-gin-web-framework/</link><pubDate>Thu, 12 May 2022 00:00:00 +0000</pubDate><guid>https://omegion.dev/2022/05/how-to-setup-redis-cache-for-gin-web-framework/</guid><description>Discover how Redis caching in Go Gin Tonic enhances web speed, optimizes APIs by efficient data storage, and reduces database queries.</description><content:encoded><![CDATA[<h2 id="introduction">Introduction</h2>
<p>When we access a website, we anticipate a swift loading experience. If it lags, we tend to navigate elsewhere. Hence, the speed of websites is crucial. Employing a cache is a key method to boost website speed. A cache serves as a brief repository for swiftly accessible data. While many websites utilize CDNs (Content Delivery Networks) to cache static content such as images, CSS, and JavaScript files, what about dynamic content?</p>
<p>Certain queries exhibit varying response times due to disparities in processing power and database access requirements. For queries that don&rsquo;t necessitate real-time data, we can implement caching to expedite their results.</p>
<p>In this post, we&rsquo;ll explore the process of configuring Redis caching for Go Gin Tonic, allowing you to optimize your API queries by retrieving cached responses without accessing the database.</p>
<h2 id="prerequisites">Prerequisites</h2>
<ol>
<li><a href="https://go.dev/" target="_blank" rel="noopener noreferrer">Go</a>
 1.16 or higher</li>
<li><a href="https://redis.io/" target="_blank" rel="noopener noreferrer">Redis</a>
 6.2 or higher</li>
<li><a href="https://github.com/gin-gonic/gin" target="_blank" rel="noopener noreferrer">Gin</a>
 1.7 or higher</li>
<li><a href="https://github.com/chenyahui/gin-cache" target="_blank" rel="noopener noreferrer">gin-cache</a>
 1.8 or higher</li>
</ol>
<h2 id="what-is-gin-framework">What is Gin Framework</h2>
<p>Gin is a popular web framework for building web applications and APIs in the Go programming language (Golang). It&rsquo;s designed to be minimalistic yet highly efficient, making it a top choice for developers who want to create performant applications with minimal boilerplate code.</p>
<p>The key features of the Gin web framework for Go (Golang) in brief are:</p>
<ol>
<li><strong>High Performance:</strong> Known for its speed and efficiency.</li>
<li><strong>Lightweight:</strong> Minimal overhead, with flexibility to add components.</li>
<li><strong>Middleware Support:</strong> Easily integrate functionality like authentication and logging.</li>
<li><strong>Efficient Routing:</strong> Intuitive routing system for URL handling.</li>
<li><strong>Validation:</strong> Built-in support for data validation.</li>
<li><strong>Error Handling:</strong> Effective error management.</li>
<li><strong>JSON Parsing:</strong> Simplified JSON handling.</li>
<li><strong>Modular:</strong> Use as a standalone framework or integrate with other libraries.</li>
</ol>
<h2 id="what-is-redis">What is Redis</h2>
<p>Redis, short for &ldquo;Remote Dictionary Server,&rdquo; is an open-source, in-memory data store and caching system. It is often referred to as a &ldquo;data structure server&rdquo; because it supports various data structures like strings, lists, sets, hashes, and more. Redis is known for its exceptional speed and is widely used as a caching mechanism and real-time data store.</p>
<h2 id="getting-started">Getting Started</h2>
<h3 id="1-install-gin-framework">1. Install Gin Framework</h3>
<p>Let&rsquo;s start by installing Gin Framework. To do so, run the following command:</p>
<div class="code-block">
  <div class="code-block-header">
    <span class="code-block-lang">shell</span>
    <button type="button" class="code-copy" aria-label="Copy code">
      <span class="code-copy-icon"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><rect x="9" y="9" width="13" height="13" rx="2" ry="2"/><path d="M5 15H4a2 2 0 01-2-2V4a2 2 0 012-2h9a2 2 0 012 2v1"/></svg>
</span>
      <span class="code-copy-icon code-copy-icon-check"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><polyline points="20 6 9 17 4 12"/></svg>
</span>
      <span class="code-copy-label">Copy</span>
    </button>
  </div>
  <div class="highlight"><pre tabindex="0" class="chroma"><code class="language-shell" data-lang="shell"><span class="line"><span class="cl">$ go get -u github.com/gin-gonic/gin</span></span></code></pre></div>
</div>
<h3 id="2-create-a-go-file">2. Create a Go file</h3>
<p>First, create a file called example.go:</p>
<div class="code-block">
  <div class="code-block-header">
    <span class="code-block-lang">shell</span>
    <button type="button" class="code-copy" aria-label="Copy code">
      <span class="code-copy-icon"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><rect x="9" y="9" width="13" height="13" rx="2" ry="2"/><path d="M5 15H4a2 2 0 01-2-2V4a2 2 0 012-2h9a2 2 0 012 2v1"/></svg>
</span>
      <span class="code-copy-icon code-copy-icon-check"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><polyline points="20 6 9 17 4 12"/></svg>
</span>
      <span class="code-copy-label">Copy</span>
    </button>
  </div>
  <div class="highlight"><pre tabindex="0" class="chroma"><code class="language-shell" data-lang="shell"><span class="line"><span class="cl"><span class="c1"># assume the following codes in main.go file</span>
</span></span><span class="line"><span class="cl">$ touch main.go</span></span></code></pre></div>
</div>
<h3 id="3-lets-create-a-basic-gin-application">3. Let&rsquo;s create a basic Gin application</h3>
<p>Create a basic Gin application in the main.go file:</p>
<div class="code-block">
  <div class="code-block-header">
    <span class="code-block-lang">go</span>
    <button type="button" class="code-copy" aria-label="Copy code">
      <span class="code-copy-icon"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><rect x="9" y="9" width="13" height="13" rx="2" ry="2"/><path d="M5 15H4a2 2 0 01-2-2V4a2 2 0 012-2h9a2 2 0 012 2v1"/></svg>
</span>
      <span class="code-copy-icon code-copy-icon-check"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><polyline points="20 6 9 17 4 12"/></svg>
</span>
      <span class="code-copy-label">Copy</span>
    </button>
  </div>
  <div class="highlight"><pre tabindex="0" class="chroma"><code class="language-go" data-lang="go"><span class="line"><span class="cl"><span class="kn">package</span><span class="w"> </span><span class="nx">main</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="kn">import</span><span class="w"> </span><span class="s">&#34;github.com/gin-gonic/gin&#34;</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="kd">func</span><span class="w"> </span><span class="nf">main</span><span class="p">()</span><span class="w"> </span><span class="p">{</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">	</span><span class="nx">r</span><span class="w"> </span><span class="o">:=</span><span class="w"> </span><span class="nx">gin</span><span class="p">.</span><span class="nf">Default</span><span class="p">()</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">	</span><span class="nx">r</span><span class="p">.</span><span class="nf">GET</span><span class="p">(</span><span class="s">&#34;/ping&#34;</span><span class="p">,</span><span class="w"> </span><span class="kd">func</span><span class="p">(</span><span class="nx">c</span><span class="w"> </span><span class="o">*</span><span class="nx">gin</span><span class="p">.</span><span class="nx">Context</span><span class="p">)</span><span class="w"> </span><span class="p">{</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">		</span><span class="nx">c</span><span class="p">.</span><span class="nf">JSON</span><span class="p">(</span><span class="mi">200</span><span class="p">,</span><span class="w"> </span><span class="nx">gin</span><span class="p">.</span><span class="nx">H</span><span class="p">{</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">			</span><span class="s">&#34;message&#34;</span><span class="p">:</span><span class="w"> </span><span class="s">&#34;pong&#34;</span><span class="p">,</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">		</span><span class="p">})</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">	</span><span class="p">})</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">	</span><span class="nx">r</span><span class="p">.</span><span class="nf">Run</span><span class="p">()</span><span class="w"> </span><span class="c1">// listen and serve on 0.0.0.0:8080</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="p">}</span></span></span></code></pre></div>
</div>
<h3 id="4-create-redis-cache-configuration">4. Create Redis Cache configuration</h3>
<div class="code-block">
  <div class="code-block-header">
    <span class="code-block-lang">go</span>
    <button type="button" class="code-copy" aria-label="Copy code">
      <span class="code-copy-icon"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><rect x="9" y="9" width="13" height="13" rx="2" ry="2"/><path d="M5 15H4a2 2 0 01-2-2V4a2 2 0 012-2h9a2 2 0 012 2v1"/></svg>
</span>
      <span class="code-copy-icon code-copy-icon-check"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><polyline points="20 6 9 17 4 12"/></svg>
</span>
      <span class="code-copy-label">Copy</span>
    </button>
  </div>
  <div class="highlight"><pre tabindex="0" class="chroma"><code class="language-go" data-lang="go"><span class="line"><span class="cl"><span class="kn">package</span><span class="w"> </span><span class="nx">main</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="kn">import</span><span class="w"> </span><span class="p">(</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">	</span><span class="s">&#34;fmt&#34;</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">	</span><span class="s">&#34;os&#34;</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">	</span><span class="s">&#34;time&#34;</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">	</span><span class="s">&#34;github.com/counterapi/api/pkg&#34;</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">	</span><span class="s">&#34;github.com/chenyahui/gin-cache/persist&#34;</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">	</span><span class="s">&#34;github.com/go-redis/redis/v8&#34;</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="p">)</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="c1">// RedisCache is a config struct for redis cache.</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="kd">type</span><span class="w"> </span><span class="nx">RedisCache</span><span class="w"> </span><span class="kd">struct</span><span class="w"> </span><span class="p">{</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">	</span><span class="nx">Store</span><span class="w">            </span><span class="o">*</span><span class="nx">persist</span><span class="p">.</span><span class="nx">RedisStore</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">	</span><span class="nx">DefaultCacheTime</span><span class="w"> </span><span class="nx">time</span><span class="p">.</span><span class="nx">Duration</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="p">}</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="c1">// SetupRedisCache sets the redis up.</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="kd">func</span><span class="w"> </span><span class="nf">SetupRedisCache</span><span class="p">()</span><span class="w"> </span><span class="o">*</span><span class="nx">RedisCache</span><span class="w"> </span><span class="p">{</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">	</span><span class="k">return</span><span class="w"> </span><span class="o">&amp;</span><span class="nx">RedisCache</span><span class="p">{</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">		</span><span class="nx">Store</span><span class="p">:</span><span class="w"> </span><span class="nx">persist</span><span class="p">.</span><span class="nf">NewRedisStore</span><span class="p">(</span><span class="nx">redis</span><span class="p">.</span><span class="nf">NewClient</span><span class="p">(</span><span class="o">&amp;</span><span class="nx">redis</span><span class="p">.</span><span class="nx">Options</span><span class="p">{</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">			</span><span class="nx">Network</span><span class="p">:</span><span class="w"> </span><span class="s">&#34;tcp&#34;</span><span class="p">,</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">			</span><span class="nx">Addr</span><span class="p">:</span><span class="w"> </span><span class="nx">fmt</span><span class="p">.</span><span class="nf">Sprintf</span><span class="p">(</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">				</span><span class="s">&#34;%s:%s&#34;</span><span class="p">,</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">				</span><span class="nx">os</span><span class="p">.</span><span class="nf">Getenv</span><span class="p">(</span><span class="s">&#34;REDIS_HOST&#34;</span><span class="p">),</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">				</span><span class="nx">os</span><span class="p">.</span><span class="nf">Getenv</span><span class="p">(</span><span class="s">&#34;REDIS_PORT&#34;</span><span class="p">),</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">			</span><span class="p">),</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">		</span><span class="p">})),</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">		</span><span class="nx">DefaultCacheTime</span><span class="p">:</span><span class="w"> </span><span class="mi">10</span><span class="w"> </span><span class="o">*</span><span class="w"> </span><span class="nx">time</span><span class="p">.</span><span class="nx">Second</span><span class="p">,</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">	</span><span class="p">}</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="p">}</span></span></span></code></pre></div>
</div>
<h3 id="5-lets-use-the-config-to-cache-the-response">5. Let&rsquo;s use the config to cache the response</h3>
<p>Use the <code>RedisCache</code> struct to cache the response of <code>/ping</code> endpoint. This will cache the response for 10 seconds.</p>
<div class="code-block">
  <div class="code-block-header">
    <span class="code-block-lang">go</span>
    <button type="button" class="code-copy" aria-label="Copy code">
      <span class="code-copy-icon"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><rect x="9" y="9" width="13" height="13" rx="2" ry="2"/><path d="M5 15H4a2 2 0 01-2-2V4a2 2 0 012-2h9a2 2 0 012 2v1"/></svg>
</span>
      <span class="code-copy-icon code-copy-icon-check"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><polyline points="20 6 9 17 4 12"/></svg>
</span>
      <span class="code-copy-label">Copy</span>
    </button>
  </div>
  <div class="highlight"><pre tabindex="0" class="chroma"><code class="language-go" data-lang="go"><span class="line"><span class="cl"><span class="kn">package</span><span class="w"> </span><span class="nx">main</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="kn">import</span><span class="w"> </span><span class="s">&#34;github.com/gin-gonic/gin&#34;</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="kd">func</span><span class="w"> </span><span class="nf">main</span><span class="p">()</span><span class="w"> </span><span class="p">{</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">	</span><span class="nx">r</span><span class="w"> </span><span class="o">:=</span><span class="w"> </span><span class="nx">gin</span><span class="p">.</span><span class="nf">Default</span><span class="p">()</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">	</span><span class="nx">cacheConfig</span><span class="w"> </span><span class="o">:=</span><span class="w"> </span><span class="nf">SetupRedisCache</span><span class="p">()</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">	</span><span class="nx">r</span><span class="p">.</span><span class="nf">GET</span><span class="p">(</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">		</span><span class="s">&#34;/ping&#34;</span><span class="p">,</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">		</span><span class="nx">cache</span><span class="p">.</span><span class="nf">CacheByRequestURI</span><span class="p">(</span><span class="nx">cacheConfig</span><span class="p">.</span><span class="nx">Store</span><span class="p">,</span><span class="w"> </span><span class="nx">cacheConfig</span><span class="p">.</span><span class="nx">DefaultCacheTime</span><span class="p">),</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">		</span><span class="kd">func</span><span class="p">(</span><span class="nx">c</span><span class="w"> </span><span class="o">*</span><span class="nx">gin</span><span class="p">.</span><span class="nx">Context</span><span class="p">)</span><span class="w"> </span><span class="p">{</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">			</span><span class="nx">c</span><span class="p">.</span><span class="nf">JSON</span><span class="p">(</span><span class="mi">200</span><span class="p">,</span><span class="w"> </span><span class="nx">gin</span><span class="p">.</span><span class="nx">H</span><span class="p">{</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">				</span><span class="s">&#34;message&#34;</span><span class="p">:</span><span class="w"> </span><span class="s">&#34;pong&#34;</span><span class="p">,</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">			</span><span class="p">})</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">		</span><span class="p">})</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">	</span><span class="nx">r</span><span class="p">.</span><span class="nf">Run</span><span class="p">()</span><span class="w"> </span><span class="c1">// listen and serve on 0.0.0.0:8080</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="p">}</span></span></span></code></pre></div>
</div>
<h2 id="conclusion">Conclusion</h2>
<p>To boost website speed, one of the most effective techniques is the use of caching. Caching involves temporarily storing frequently accessed data so that it can be rapidly retrieved without the need to access the original data source. While Content Delivery Networks (CDNs) are commonly used to cache static assets like images and scripts, the question remains: how can we efficiently cache dynamic content?</p>
<p>This blog post has explored the process of setting up Redis caching for Go Gin Tonic, a popular web framework for building high-performance web applications and APIs in the Go programming language (Golang). By leveraging Redis, you can optimize your API queries by retrieving cached responses, eliminating the need for repeated database access.</p>
<p>By following these steps, you can harness the power of Redis caching to enhance the speed and responsiveness of your Go Gin Tonic applications, ensuring a smoother and more efficient user experience. Caching dynamic content has never been easier, thanks to the combination of Gin and Redis.</p>
]]></content:encoded></item><item><title>How to Create Multi-stage Dockerfile</title><link>https://omegion.dev/2022/03/how-to-create-multi-stage-dockerfile/</link><pubDate>Fri, 25 Mar 2022 11:15:14 +0100</pubDate><guid>https://omegion.dev/2022/03/how-to-create-multi-stage-dockerfile/</guid><description>Reduce your image size by using multi-stage Dockerfile</description><content:encoded><![CDATA[<h2 id="introduction">Introduction</h2>
<p>A Dockerfile is a document that contains all the instructions to build an image.
Basically, you create an image on top of the existing image with given
specifications on the Dockerfile. Then Docker will take this file and follow
your instructions to create a brand new image on top of the base image.</p>
<p>A base image is an image built by other users. Usually, the base image is a
fundamental OS image. For example <code>alpine</code>
or <code>ubuntu</code>. The base image gives you a base that you can instruct. Usually,
we install our custom packages on top of the base image. However, we
need multiple base images based on different needs.</p>
<h2 id="prerequisites">Prerequisites</h2>
<ul>
<li><code>docker</code></li>
</ul>
<h2 id="what-is-multi-stage">What is multi-stage</h2>
<p>Say that you have <code>Go</code> project and you need to build it. For this case, you
will need some packages only required for the build itself we do not want
to keep these packages in our image just for build purposes.</p>
<h2 id="multi-stage-image-example">Multi-stage Image Example</h2>
<p>That is the reason that we need to use multi-stage to reduce our image size.
Let&rsquo;s create an image for an example:</p>
<div class="code-block">
  <div class="code-block-header">
    <span class="code-block-lang">Dockerfile</span>
    <button type="button" class="code-copy" aria-label="Copy code">
      <span class="code-copy-icon"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><rect x="9" y="9" width="13" height="13" rx="2" ry="2"/><path d="M5 15H4a2 2 0 01-2-2V4a2 2 0 012-2h9a2 2 0 012 2v1"/></svg>
</span>
      <span class="code-copy-icon code-copy-icon-check"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><polyline points="20 6 9 17 4 12"/></svg>
</span>
      <span class="code-copy-label">Copy</span>
    </button>
  </div>
  <div class="highlight"><pre tabindex="0" class="chroma"><code class="language-Dockerfile" data-lang="Dockerfile"><span class="line"><span class="cl"><span class="k">FROM</span><span class="w"> </span><span class="s">golang:1.17-alpine3.14</span><span class="w"> </span><span class="k">AS</span><span class="w"> </span><span class="s">builder</span><span class="err">
</span></span></span><span class="line"><span class="cl"><span class="err">
</span></span></span><span class="line"><span class="cl"><span class="k">WORKDIR</span><span class="w"> </span><span class="s">/app</span><span class="err">
</span></span></span><span class="line"><span class="cl"><span class="err">
</span></span></span><span class="line"><span class="cl"><span class="k">COPY</span> ./ /app<span class="err">
</span></span></span><span class="line"><span class="cl"><span class="err">
</span></span></span><span class="line"><span class="cl"><span class="k">RUN</span> apk update <span class="o">&amp;&amp;</span> <span class="se">\
</span></span></span><span class="line"><span class="cl">  apk add ca-certificates gettext git make curl unzip <span class="o">&amp;&amp;</span> <span class="se">\
</span></span></span><span class="line"><span class="cl">  rm -rf /tmp/* <span class="o">&amp;&amp;</span> <span class="se">\
</span></span></span><span class="line"><span class="cl">  rm -rf /var/cache/apk/* <span class="o">&amp;&amp;</span> <span class="se">\
</span></span></span><span class="line"><span class="cl">  rm -rf /var/tmp/*<span class="err">
</span></span></span><span class="line"><span class="cl">  <span class="err">
</span></span></span><span class="line"><span class="cl"><span class="err">
</span></span></span><span class="line"><span class="cl"><span class="k">RUN</span> go build -o my-binary main.go</span></span></code></pre></div>
</div>
<p>In this <code>Dockerfile</code> example, we are building <code>main.go</code> file to binary. As
you can see we are installing a bunch of other packages for building purposes
but we do not need them for later usage of the image. This <code>Dockerfile</code>
creates an image sized <code>572MB</code>.</p>
<p>Let&rsquo;s improve the <code>Dockerfile</code> to adapt it to use multi-stage.</p>
<div class="code-block">
  <div class="code-block-header">
    <span class="code-block-lang">Dockerfile</span>
    <button type="button" class="code-copy" aria-label="Copy code">
      <span class="code-copy-icon"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><rect x="9" y="9" width="13" height="13" rx="2" ry="2"/><path d="M5 15H4a2 2 0 01-2-2V4a2 2 0 012-2h9a2 2 0 012 2v1"/></svg>
</span>
      <span class="code-copy-icon code-copy-icon-check"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><polyline points="20 6 9 17 4 12"/></svg>
</span>
      <span class="code-copy-label">Copy</span>
    </button>
  </div>
  <div class="highlight"><pre tabindex="0" class="chroma"><code class="language-Dockerfile" data-lang="Dockerfile"><span class="line"><span class="cl"><span class="k">FROM</span><span class="w"> </span><span class="s">golang:1.17-alpine3.14</span><span class="w"> </span><span class="k">AS</span><span class="w"> </span><span class="s">builder</span><span class="err">
</span></span></span><span class="line"><span class="cl"><span class="err">
</span></span></span><span class="line"><span class="cl"><span class="k">WORKDIR</span><span class="w"> </span><span class="s">/app</span><span class="err">
</span></span></span><span class="line"><span class="cl"><span class="err">
</span></span></span><span class="line"><span class="cl"><span class="k">COPY</span> ./ /app<span class="err">
</span></span></span><span class="line"><span class="cl"><span class="err">
</span></span></span><span class="line"><span class="cl"><span class="k">RUN</span> apk update <span class="o">&amp;&amp;</span> <span class="se">\
</span></span></span><span class="line"><span class="cl">  apk add ca-certificates gettext git make curl unzip <span class="o">&amp;&amp;</span> <span class="se">\
</span></span></span><span class="line"><span class="cl">  rm -rf /tmp/* <span class="o">&amp;&amp;</span> <span class="se">\
</span></span></span><span class="line"><span class="cl">  rm -rf /var/cache/apk/* <span class="o">&amp;&amp;</span> <span class="se">\
</span></span></span><span class="line"><span class="cl">  rm -rf /var/tmp/*<span class="err">
</span></span></span><span class="line"><span class="cl"><span class="err">
</span></span></span><span class="line"><span class="cl"><span class="err">
</span></span></span><span class="line"><span class="cl"><span class="k">RUN</span> go build -o my-binary main.go<span class="err">
</span></span></span><span class="line"><span class="cl"><span class="err">
</span></span></span><span class="line"><span class="cl"><span class="k">FROM</span><span class="w"> </span><span class="s">alpine:3.14</span><span class="err">
</span></span></span><span class="line"><span class="cl"><span class="err">
</span></span></span><span class="line"><span class="cl"><span class="k">COPY</span> --from<span class="o">=</span>builder /app/my-binary /bin/my-binary<span class="err">
</span></span></span><span class="line"><span class="cl"><span class="err">
</span></span></span><span class="line"><span class="cl"><span class="k">ENTRYPOINT</span> <span class="p">[</span><span class="s2">&#34;my-binary&#34;</span><span class="p">]</span></span></span></code></pre></div>
</div>
<p>We added another <code>FROM</code> which creates another stage with base image <code>alpine</code>
and copies the binary from the previous stage called <code>builder</code> and put it to
<code>/bin/my-binary</code> path.</p>
<p>The new image size with multi-stage <code>Dockerfile</code> is <code> 7.36MB</code>. So, we only
get the binary from the previous stage called <code>builder</code> the other packages that
we installed for building the binary is not included in our final stage.</p>
<h2 id="conclusion">Conclusion</h2>
<p>We install lots of other packages on the base image to build staff. But, we
do not always need those packages in the final image. Multi-stage
<code>Dockerfile</code> helps us to reduce the image size dramatically from <code>572MB</code> to
<code>7.36MB</code>.</p>
]]></content:encoded></item><item><title>S3 Secrets Manager</title><link>https://omegion.dev/projects/s3-secrets-manager/</link><pubDate>Sat, 01 Jan 2022 00:00:00 +0000</pubDate><guid>https://omegion.dev/projects/s3-secrets-manager/</guid><description>Cheapest Secrets Manager on AWS S3.</description><content:encoded></content:encoded></item><item><title>How to Use Vault Signed SSH Certificate Manager</title><link>https://omegion.dev/2021/06/how-to-use-vault-signed-ssh-certificate-manager/</link><pubDate>Thu, 24 Jun 2021 00:00:00 +0000</pubDate><guid>https://omegion.dev/2021/06/how-to-use-vault-signed-ssh-certificate-manager/</guid><description/><content:encoded><![CDATA[<p class="lead">A tool to use Vault Signed SSH Certificate Manager to SSH your server.</p>

<h2 id="introduction">Introduction</h2>
<p>Vault is a popular secret manager from Hashicorp. Using a Signed certificate to SSH
to your server adds another layer of security. I created
<strong><a href="https://github.com/omegion/vault-ssh" target="_blank" rel="noopener noreferrer">Vault Signed SSH Certificate Manager</a>
</strong> to allow other developers to
SSH servers more securely.</p>
<a class="link-row-ext project-card" href="/projects/vault-ssh-certifiacate-manager/">
  <div class="link-row-ext-inner">
    <span class="project-card-title" style="view-transition-name: vt-title-2ce8a8360bcc7f5ee1a6619d7c869778">Vault Signed SSH Certificate Manager</span>
    <div class="project-card-meta">Launched April 2021</div>
    <div class="link-row-desc">Vault Signed SSH Certificate Manager for servers.</div>
  </div>
  <span class="ext-arrow">&#8599;</span>
</a>
<h2 id="prerequisites">Prerequisites</h2>
<p>Before you get started, you will need to have a number of things.</p>
<ol>
<li>Running Vault server</li>
</ol>
<h2 id="installing-ssh-manager-binary">Installing SSH Manager Binary</h2>
<p>In order to be able to run <code>ssh-manager</code>, we need to install the binary to the
local machine. For this, run the following go command to get the package.</p>
<div class="code-block">
  <div class="code-block-header">
    <span class="code-block-lang">shell</span>
    <button type="button" class="code-copy" aria-label="Copy code">
      <span class="code-copy-icon"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><rect x="9" y="9" width="13" height="13" rx="2" ry="2"/><path d="M5 15H4a2 2 0 01-2-2V4a2 2 0 012-2h9a2 2 0 012 2v1"/></svg>
</span>
      <span class="code-copy-icon code-copy-icon-check"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><polyline points="20 6 9 17 4 12"/></svg>
</span>
      <span class="code-copy-label">Copy</span>
    </button>
  </div>
  <div class="highlight"><pre tabindex="0" class="chroma"><code class="language-shell" data-lang="shell"><span class="line"><span class="cl">go install github.com/omegion/vault-ssh@latest</span></span></code></pre></div>
</div>
<p>Let&rsquo;s test that the binary is working:</p>
<div class="code-block">
  <div class="code-block-header">
    <span class="code-block-lang">shell</span>
    <button type="button" class="code-copy" aria-label="Copy code">
      <span class="code-copy-icon"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><rect x="9" y="9" width="13" height="13" rx="2" ry="2"/><path d="M5 15H4a2 2 0 01-2-2V4a2 2 0 012-2h9a2 2 0 012 2v1"/></svg>
</span>
      <span class="code-copy-icon code-copy-icon-check"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><polyline points="20 6 9 17 4 12"/></svg>
</span>
      <span class="code-copy-label">Copy</span>
    </button>
  </div>
  <div class="highlight"><pre tabindex="0" class="chroma"><code class="language-shell" data-lang="shell"><span class="line"><span class="cl">vault-ssh --help</span></span></code></pre></div>
</div>
<p>If everything went well, you will see the CLI help as below:</p>
<div class="code-block">
  <div class="code-block-header">
    <span class="code-block-lang">shell</span>
    <button type="button" class="code-copy" aria-label="Copy code">
      <span class="code-copy-icon"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><rect x="9" y="9" width="13" height="13" rx="2" ry="2"/><path d="M5 15H4a2 2 0 01-2-2V4a2 2 0 012-2h9a2 2 0 012 2v1"/></svg>
</span>
      <span class="code-copy-icon code-copy-icon-check"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><polyline points="20 6 9 17 4 12"/></svg>
</span>
      <span class="code-copy-label">Copy</span>
    </button>
  </div>
  <div class="highlight"><pre tabindex="0" class="chroma"><code class="language-shell" data-lang="shell"><span class="line"><span class="cl">❯ vault-ssh --help
</span></span><span class="line"><span class="cl">CLI <span class="nb">command</span> to manage SSH connections with Vault
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl">Usage:
</span></span><span class="line"><span class="cl">  vault-ssh <span class="o">[</span>command<span class="o">]</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl">Available Commands:
</span></span><span class="line"><span class="cl">  certificate Manages certificates <span class="k">for</span> SSH engine.
</span></span><span class="line"><span class="cl">  <span class="nb">enable</span>      Enables SSH Engine.
</span></span><span class="line"><span class="cl">  <span class="nb">help</span>        Help about any <span class="nb">command</span>
</span></span><span class="line"><span class="cl">  role        Manages roles <span class="k">for</span> SSH engine.
</span></span><span class="line"><span class="cl">  sign        Signs given public key with SSH engine and role.
</span></span><span class="line"><span class="cl">  version     Print the version/build number
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl">Flags:
</span></span><span class="line"><span class="cl">  -h, --help   <span class="nb">help</span> <span class="k">for</span> vault-ssh
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl">Use <span class="s2">&#34;vault-ssh [command] --help&#34;</span> <span class="k">for</span> more information about a command.</span></span></code></pre></div>
</div>
<h2 id="create-ssh-engine-and-role">Create SSH engine and role.</h2>
<ol>
<li>Enable a SSH engine in your Vault.</li>
</ol>
<div class="code-block">
  <div class="code-block-header">
    <span class="code-block-lang">shell</span>
    <button type="button" class="code-copy" aria-label="Copy code">
      <span class="code-copy-icon"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><rect x="9" y="9" width="13" height="13" rx="2" ry="2"/><path d="M5 15H4a2 2 0 01-2-2V4a2 2 0 012-2h9a2 2 0 012 2v1"/></svg>
</span>
      <span class="code-copy-icon code-copy-icon-check"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><polyline points="20 6 9 17 4 12"/></svg>
</span>
      <span class="code-copy-label">Copy</span>
    </button>
  </div>
  <div class="highlight"><pre tabindex="0" class="chroma"><code class="language-shell" data-lang="shell"><span class="line"><span class="cl">vault-ssh <span class="nb">enable</span> --path my-ssh-signer</span></span></code></pre></div>
</div>
<ol start="2">
<li>Generate a Certificate CA for the engine.</li>
</ol>
<div class="code-block">
  <div class="code-block-header">
    <span class="code-block-lang">shell</span>
    <button type="button" class="code-copy" aria-label="Copy code">
      <span class="code-copy-icon"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><rect x="9" y="9" width="13" height="13" rx="2" ry="2"/><path d="M5 15H4a2 2 0 01-2-2V4a2 2 0 012-2h9a2 2 0 012 2v1"/></svg>
</span>
      <span class="code-copy-icon code-copy-icon-check"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><polyline points="20 6 9 17 4 12"/></svg>
</span>
      <span class="code-copy-label">Copy</span>
    </button>
  </div>
  <div class="highlight"><pre tabindex="0" class="chroma"><code class="language-shell" data-lang="shell"><span class="line"><span class="cl">vault-ssh certificate create --engine my-ssh-signer</span></span></code></pre></div>
</div>
<ol start="3">
<li>Read created certificate to put on your server.</li>
</ol>
<div class="code-block">
  <div class="code-block-header">
    <span class="code-block-lang">shell</span>
    <button type="button" class="code-copy" aria-label="Copy code">
      <span class="code-copy-icon"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><rect x="9" y="9" width="13" height="13" rx="2" ry="2"/><path d="M5 15H4a2 2 0 01-2-2V4a2 2 0 012-2h9a2 2 0 012 2v1"/></svg>
</span>
      <span class="code-copy-icon code-copy-icon-check"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><polyline points="20 6 9 17 4 12"/></svg>
</span>
      <span class="code-copy-label">Copy</span>
    </button>
  </div>
  <div class="highlight"><pre tabindex="0" class="chroma"><code class="language-shell" data-lang="shell"><span class="line"><span class="cl">vault-ssh certificate <span class="nb">read</span> --engine my-ssh-signer</span></span></code></pre></div>
</div>
<ol start="4">
<li>Create a role for the engine.</li>
</ol>
<div class="code-block">
  <div class="code-block-header">
    <span class="code-block-lang">shell</span>
    <button type="button" class="code-copy" aria-label="Copy code">
      <span class="code-copy-icon"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><rect x="9" y="9" width="13" height="13" rx="2" ry="2"/><path d="M5 15H4a2 2 0 01-2-2V4a2 2 0 012-2h9a2 2 0 012 2v1"/></svg>
</span>
      <span class="code-copy-icon code-copy-icon-check"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><polyline points="20 6 9 17 4 12"/></svg>
</span>
      <span class="code-copy-label">Copy</span>
    </button>
  </div>
  <div class="highlight"><pre tabindex="0" class="chroma"><code class="language-shell" data-lang="shell"><span class="line"><span class="cl">vault-ssh role create --name omegion --engine my-ssh-signer</span></span></code></pre></div>
</div>
<ol start="5">
<li>Sign your public key with a role. The generated file will be written in <code>signed-key.pub</code> in this example.</li>
</ol>
<div class="code-block">
  <div class="code-block-header">
    <span class="code-block-lang">shell</span>
    <button type="button" class="code-copy" aria-label="Copy code">
      <span class="code-copy-icon"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><rect x="9" y="9" width="13" height="13" rx="2" ry="2"/><path d="M5 15H4a2 2 0 01-2-2V4a2 2 0 012-2h9a2 2 0 012 2v1"/></svg>
</span>
      <span class="code-copy-icon code-copy-icon-check"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><polyline points="20 6 9 17 4 12"/></svg>
</span>
      <span class="code-copy-label">Copy</span>
    </button>
  </div>
  <div class="highlight"><pre tabindex="0" class="chroma"><code class="language-shell" data-lang="shell"><span class="line"><span class="cl">vault-ssh sign <span class="se">\
</span></span></span><span class="line"><span class="cl">  --role omegion <span class="se">\
</span></span></span><span class="line"><span class="cl">  --engine my-ssh-signer <span class="se">\
</span></span></span><span class="line"><span class="cl">  --public-key ~/.ssh/id_rsa.pub &gt; signed-key.pub</span></span></code></pre></div>
</div>
<ol start="6">
<li>SSH your server with signed key.</li>
</ol>
<div class="code-block">
  <div class="code-block-header">
    <span class="code-block-lang">shell</span>
    <button type="button" class="code-copy" aria-label="Copy code">
      <span class="code-copy-icon"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><rect x="9" y="9" width="13" height="13" rx="2" ry="2"/><path d="M5 15H4a2 2 0 01-2-2V4a2 2 0 012-2h9a2 2 0 012 2v1"/></svg>
</span>
      <span class="code-copy-icon code-copy-icon-check"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><polyline points="20 6 9 17 4 12"/></svg>
</span>
      <span class="code-copy-label">Copy</span>
    </button>
  </div>
  <div class="highlight"><pre tabindex="0" class="chroma"><code class="language-shell" data-lang="shell"><span class="line"><span class="cl">ssh -i signed-key.pub -i ~/.ssh/id_rsa root@1.1.1.1</span></span></code></pre></div>
</div>
<h2 id="conclusion">Conclusion</h2>
<p>In this tutorial, we enabled SSH engine in Vault, then we generated CA certificate.
We used the generated CA certificate to the server we want to SSH. Also, we created
a role in Vault within the created engine with static configuration. Finally,
we signed our SSH key with Vault then used a temporary CA certificate to SSH to
the server.</p>
]]></content:encoded></item><item><title>How to Store SSH Keys on Password Managers</title><link>https://omegion.dev/2021/06/how-to-store-ssh-keys-on-password-managers/</link><pubDate>Sun, 20 Jun 2021 00:00:00 +0000</pubDate><guid>https://omegion.dev/2021/06/how-to-store-ssh-keys-on-password-managers/</guid><description>A tool to manage and store your SSH keys on password managers 1Password, Bitwarden and AWS S3.</description><content:encoded><![CDATA[<p class="lead">A tool to manage and store your SSH keys on password managers 1Password, Bitwarden and AWS S3</p>

<h2 id="introduction">Introduction</h2>
<p>Managing SSH keys are hard. Especially if you would like to move them to different
devices, or sharing the same keys across devices add more complexity. In order to
solve this problem, I created <strong><a href="https://github.com/omegion/ssh-manager" target="_blank" rel="noopener noreferrer">SSH Manager</a>
</strong> to allow other developers to manage
their keys on their favourite password managers securely.</p>
<p>SSH Managers supports only <strong><a href="https://1password.com/" target="_blank" rel="noopener noreferrer">1Password</a>
</strong> and <strong><a href="https://bitwarden.com/" target="_blank" rel="noopener noreferrer">Bitwarden</a>
</strong> at the moment, but more
managers are on the way.</p>
<a class="link-row-ext project-card" href="/projects/ssh-manager/">
  <div class="link-row-ext-inner">
    <span class="project-card-title" style="view-transition-name: vt-title-23260f18a6c63dfe51c54f5667c2621e">SSH Key Manager</span>
    <div class="project-card-meta">Launched February 2021</div>
    <div class="link-row-desc">SSH Key Manager for 1Password, Bitwarden and AWS S3.</div>
  </div>
  <span class="ext-arrow">&#8599;</span>
</a>
<h2 id="prerequisites">Prerequisites</h2>
<p>Before you get started, you will need to have a number of things.</p>
<ol>
<li>Go</li>
<li>Have the <strong>Bitwarden</strong> CLI tool installed and available in the <code>$PATH</code> as <code>bw</code>.</li>
<li>Or have the <strong>1Password</strong> CLI tool installed and available in the <code>$PATH</code> as <code>op</code>.</li>
<li>Have the <code>ssh-agent</code> running in the current session.</li>
</ol>
<h2 id="installing-ssh-manager-binary">Installing SSH Manager Binary</h2>
<p>In order to be able to run <code>ssh-manager</code>, we need to install the binary to the
local machine. For this, run the following go command to get the package.</p>
<div class="code-block">
  <div class="code-block-header">
    <span class="code-block-lang">shell</span>
    <button type="button" class="code-copy" aria-label="Copy code">
      <span class="code-copy-icon"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><rect x="9" y="9" width="13" height="13" rx="2" ry="2"/><path d="M5 15H4a2 2 0 01-2-2V4a2 2 0 012-2h9a2 2 0 012 2v1"/></svg>
</span>
      <span class="code-copy-icon code-copy-icon-check"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><polyline points="20 6 9 17 4 12"/></svg>
</span>
      <span class="code-copy-label">Copy</span>
    </button>
  </div>
  <div class="highlight"><pre tabindex="0" class="chroma"><code class="language-shell" data-lang="shell"><span class="line"><span class="cl">go install github.com/omegion/ssh-manager@latest</span></span></code></pre></div>
</div>
<p>Let&rsquo;s test that the binary is working:</p>
<div class="code-block">
  <div class="code-block-header">
    <span class="code-block-lang">shell</span>
    <button type="button" class="code-copy" aria-label="Copy code">
      <span class="code-copy-icon"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><rect x="9" y="9" width="13" height="13" rx="2" ry="2"/><path d="M5 15H4a2 2 0 01-2-2V4a2 2 0 012-2h9a2 2 0 012 2v1"/></svg>
</span>
      <span class="code-copy-icon code-copy-icon-check"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><polyline points="20 6 9 17 4 12"/></svg>
</span>
      <span class="code-copy-label">Copy</span>
    </button>
  </div>
  <div class="highlight"><pre tabindex="0" class="chroma"><code class="language-shell" data-lang="shell"><span class="line"><span class="cl">ssh-manager --help</span></span></code></pre></div>
</div>
<p>If everything went well, you will see the CLI help as below:</p>
<div class="code-block">
  <div class="code-block-header">
    <span class="code-block-lang">shell</span>
    <button type="button" class="code-copy" aria-label="Copy code">
      <span class="code-copy-icon"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><rect x="9" y="9" width="13" height="13" rx="2" ry="2"/><path d="M5 15H4a2 2 0 01-2-2V4a2 2 0 012-2h9a2 2 0 012 2v1"/></svg>
</span>
      <span class="code-copy-icon code-copy-icon-check"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><polyline points="20 6 9 17 4 12"/></svg>
</span>
      <span class="code-copy-label">Copy</span>
    </button>
  </div>
  <div class="highlight"><pre tabindex="0" class="chroma"><code class="language-shell" data-lang="shell"><span class="line"><span class="cl">❯ ssh-manager --help
</span></span><span class="line"><span class="cl">CLI <span class="nb">command</span> to automatically unseal Vault
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl">Usage:
</span></span><span class="line"><span class="cl">  vault-unseal <span class="o">[</span>command<span class="o">]</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl">Available Commands:
</span></span><span class="line"><span class="cl">  add         Add Manager key to given provider.
</span></span><span class="line"><span class="cl">  get         Get Manager key from given provider.
</span></span><span class="line"><span class="cl">  <span class="nb">help</span>        Help about any <span class="nb">command</span>
</span></span><span class="line"><span class="cl">  list        List Manager keys from given provider.
</span></span><span class="line"><span class="cl">  version     Print the version/build number
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl">Flags:
</span></span><span class="line"><span class="cl">  -h, --help               <span class="nb">help</span> <span class="k">for</span> vault-unseal
</span></span><span class="line"><span class="cl">      --logFormat string   Set the logging format. One of: text<span class="p">|</span>json <span class="o">(</span>default <span class="s2">&#34;text&#34;</span><span class="o">)</span> <span class="o">(</span>default <span class="s2">&#34;text&#34;</span><span class="o">)</span>
</span></span><span class="line"><span class="cl">      --logLevel string    Set the logging level. One of: debug<span class="p">|</span>info<span class="p">|</span>warn<span class="p">|</span>error <span class="o">(</span>default <span class="s2">&#34;info&#34;</span><span class="o">)</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl">Use <span class="s2">&#34;vault-unseal [command] --help&#34;</span> <span class="k">for</span> more information about a command.</span></span></code></pre></div>
</div>
<h2 id="generate-test-ssh-keypair">Generate Test SSH Keypair</h2>
<p>Before using your SSH keys to store in Bitwarden, let&rsquo;s create a dummy keypair
and test with it.</p>
<ol>
<li>Open Terminal.</li>
<li>Paste the command below</li>
</ol>
<div class="code-block">
  <div class="code-block-header">
    <span class="code-block-lang">shell</span>
    <button type="button" class="code-copy" aria-label="Copy code">
      <span class="code-copy-icon"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><rect x="9" y="9" width="13" height="13" rx="2" ry="2"/><path d="M5 15H4a2 2 0 01-2-2V4a2 2 0 012-2h9a2 2 0 012 2v1"/></svg>
</span>
      <span class="code-copy-icon code-copy-icon-check"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><polyline points="20 6 9 17 4 12"/></svg>
</span>
      <span class="code-copy-label">Copy</span>
    </button>
  </div>
  <div class="highlight"><pre tabindex="0" class="chroma"><code class="language-shell" data-lang="shell"><span class="line"><span class="cl">ssh-keygen -t ed25519 </span></span></code></pre></div>
</div>
<ol>
<li>When you&rsquo;re prompted to &ldquo;Enter a file in which to save the key,&rdquo; enter <code>test</code>.</li>
<li>At the prompt, do not type a secure passphrase.</li>
</ol>
<h2 id="add-ssh-keys">Add SSH Keys</h2>
<p>Let&rsquo;s be sure that we have previously created keys:</p>
<div class="code-block">
  <div class="code-block-header">
    <span class="code-block-lang">shell</span>
    <button type="button" class="code-copy" aria-label="Copy code">
      <span class="code-copy-icon"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><rect x="9" y="9" width="13" height="13" rx="2" ry="2"/><path d="M5 15H4a2 2 0 01-2-2V4a2 2 0 012-2h9a2 2 0 012 2v1"/></svg>
</span>
      <span class="code-copy-icon code-copy-icon-check"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><polyline points="20 6 9 17 4 12"/></svg>
</span>
      <span class="code-copy-label">Copy</span>
    </button>
  </div>
  <div class="highlight"><pre tabindex="0" class="chroma"><code class="language-shell" data-lang="shell"><span class="line"><span class="cl">❯ ls -l test*
</span></span><span class="line"><span class="cl">-rw-------  <span class="m">1</span> X  staff  <span class="m">432</span> Mar <span class="m">30</span> 08:38 <span class="nb">test</span>
</span></span><span class="line"><span class="cl">-rw-r--r--  <span class="m">1</span> X  staff  <span class="m">112</span> Mar <span class="m">30</span> 08:38 test.pub</span></span></code></pre></div>
</div>
<p>I will use Bitwarden as a password manager for this example. Now we can add them
to Bitwarden.</p>
<div class="code-block">
  <div class="code-block-header">
    <span class="code-block-lang">shell</span>
    <button type="button" class="code-copy" aria-label="Copy code">
      <span class="code-copy-icon"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><rect x="9" y="9" width="13" height="13" rx="2" ry="2"/><path d="M5 15H4a2 2 0 01-2-2V4a2 2 0 012-2h9a2 2 0 012 2v1"/></svg>
</span>
      <span class="code-copy-icon code-copy-icon-check"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><polyline points="20 6 9 17 4 12"/></svg>
</span>
      <span class="code-copy-label">Copy</span>
    </button>
  </div>
  <div class="highlight"><pre tabindex="0" class="chroma"><code class="language-shell" data-lang="shell"><span class="line"><span class="cl">ssh-manager add --name <span class="nb">test</span> --private-key <span class="nb">test</span> --public-key test.pub --provider bw</span></span></code></pre></div>
</div>
<h2 id="get-ssh-keys">Get SSH Keys</h2>
<p>Once we have SSH keypair on Bitwarden, let&rsquo;s get them to our local machine.</p>
<div class="code-block">
  <div class="code-block-header">
    <span class="code-block-lang">shell</span>
    <button type="button" class="code-copy" aria-label="Copy code">
      <span class="code-copy-icon"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><rect x="9" y="9" width="13" height="13" rx="2" ry="2"/><path d="M5 15H4a2 2 0 01-2-2V4a2 2 0 012-2h9a2 2 0 012 2v1"/></svg>
</span>
      <span class="code-copy-icon code-copy-icon-check"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><polyline points="20 6 9 17 4 12"/></svg>
</span>
      <span class="code-copy-label">Copy</span>
    </button>
  </div>
  <div class="highlight"><pre tabindex="0" class="chroma"><code class="language-shell" data-lang="shell"><span class="line"><span class="cl">❯ ssh-manager get --name <span class="nb">test</span> --provider bw
</span></span><span class="line"><span class="cl">SSH Key <span class="nb">test</span> added.</span></span></code></pre></div>
</div>
<p>Let&rsquo;s check <code>~/.ssh/keys</code> folder if our keys are added.</p>
<div class="code-block">
  <div class="code-block-header">
    <span class="code-block-lang">shell</span>
    <button type="button" class="code-copy" aria-label="Copy code">
      <span class="code-copy-icon"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><rect x="9" y="9" width="13" height="13" rx="2" ry="2"/><path d="M5 15H4a2 2 0 01-2-2V4a2 2 0 012-2h9a2 2 0 012 2v1"/></svg>
</span>
      <span class="code-copy-icon code-copy-icon-check"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><polyline points="20 6 9 17 4 12"/></svg>
</span>
      <span class="code-copy-label">Copy</span>
    </button>
  </div>
  <div class="highlight"><pre tabindex="0" class="chroma"><code class="language-shell" data-lang="shell"><span class="line"><span class="cl">❯ ls -l ~/.ssh/keys/
</span></span><span class="line"><span class="cl">-rw-------  <span class="m">1</span> X  staff   <span class="m">432</span> Mar <span class="m">30</span> 11:05 <span class="nb">test</span>
</span></span><span class="line"><span class="cl">-rw-------  <span class="m">1</span> X  staff   <span class="m">112</span> Mar <span class="m">30</span> 11:05 test.pub</span></span></code></pre></div>
</div>
<h2 id="conclusion">Conclusion</h2>
<p>In this tutorial, we generated SSH keypair and store them in a password manager,
in this case, we used Bitwarden. Then we used SSH Manager to get the SSH keys to
the local machine and add them to <code>ssh-agent</code>.</p>
]]></content:encoded></item><item><title>How to install ArgoCD on Kubernetes cluster</title><link>https://omegion.dev/2021/06/how-to-install-argocd-on-kubernetes-cluster/</link><pubDate>Wed, 02 Jun 2021 00:00:00 +0000</pubDate><guid>https://omegion.dev/2021/06/how-to-install-argocd-on-kubernetes-cluster/</guid><description>Tutorial for How to install ArgoCD on Kubernetes cluster.</description><content:encoded><![CDATA[<h2 id="introduction">Introduction</h2>
<p>Kubernetes became one of the de facto standards for containerized applications. Many cloud platforms from the smallest
to the largest like Scaleway, DigitalOcean, AWS, Google Cloud, Azure, and IBM Cloud now provide managed services for
Kubernetes. Creating a cluster and deploying an application into it became so easy that it takes a couple of minutes to
run your application on it. As these companies abstract the architecture layer from the developer that you do not need
to maintain a cluster anymore, deployment of the application became the new main focus. There are many open-source
tools that help you to deploy your application on clusters. In my opinion, one of the famous ones is Argo CD.
In this tutorial, we will install Argo CD on a cluster, login to UI, and deploy an application with it.</p>
<h2 id="what-is-argo-cd">What is Argo CD</h2>
<p>Argo CD is a lightweight and easy to configure declarative GitOps tool. It is built to deploy applications to
Kubernetes. As continuous delivery (CD) has increasing popularity, Argo CD does provide many interesting
capabilities.  Unlike other CD tools, Argo CD is lightweight and easy to configure. It is purpose-built to deploy
applications only to Kubernetes, its UI does not have overhead UI tools. It&rsquo;s also built with a GitOps flow.
Meaning, everything ArgoCD sees as its source of truth is stored in a repository with multiple branches for
different purposes such as <code>canary</code> and <code>production</code>.</p>
<h2 id="prerequisites">Prerequisites</h2>
<p>Before you get started, you will need to have a number of things.</p>
<ol>
<li>Kubernetes cluster.</li>
<li><code>kubectl</code> installed. If not, you will need it to manage your clusters.</li>
<li>Have a kubeconfig file (default location is ~/.kube/config).</li>
<li>Have a GitHub account.</li>
</ol>
<h3 id="install-argo-cd-cli">Install Argo CD CLI</h3>
<p>Once you have your cluster set and <code>kubectl</code> installed, let&rsquo;s install the Argo CD CLI tool. We will install it with
<code>brew</code> for Mac OS, you can find other installation directives on Argo CD
<a href="https://argo-cd.readthedocs.io/en/stable/cli_installation/" target="_blank" rel="noopener noreferrer">documentation</a>
.</p>
<div class="code-block">
  <div class="code-block-header">
    <span class="code-block-lang">shell</span>
    <button type="button" class="code-copy" aria-label="Copy code">
      <span class="code-copy-icon"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><rect x="9" y="9" width="13" height="13" rx="2" ry="2"/><path d="M5 15H4a2 2 0 01-2-2V4a2 2 0 012-2h9a2 2 0 012 2v1"/></svg>
</span>
      <span class="code-copy-icon code-copy-icon-check"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><polyline points="20 6 9 17 4 12"/></svg>
</span>
      <span class="code-copy-label">Copy</span>
    </button>
  </div>
  <div class="highlight"><pre tabindex="0" class="chroma"><code class="language-shell" data-lang="shell"><span class="line"><span class="cl">brew tap argoproj/tap
</span></span><span class="line"><span class="cl">brew install argoproj/tap/argocd</span></span></code></pre></div>
</div>
<h3 id="install-argo-cd-to-the-cluster">Install Argo CD to the cluster</h3>
<p>We need to install Argo CD to our cluster to be able to deploy applications.</p>
<div class="code-block">
  <div class="code-block-header">
    <span class="code-block-lang">bash</span>
    <button type="button" class="code-copy" aria-label="Copy code">
      <span class="code-copy-icon"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><rect x="9" y="9" width="13" height="13" rx="2" ry="2"/><path d="M5 15H4a2 2 0 01-2-2V4a2 2 0 012-2h9a2 2 0 012 2v1"/></svg>
</span>
      <span class="code-copy-icon code-copy-icon-check"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><polyline points="20 6 9 17 4 12"/></svg>
</span>
      <span class="code-copy-label">Copy</span>
    </button>
  </div>
  <div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="cl">kubectl create namespace argocd
</span></span><span class="line"><span class="cl">kubectl apply -n argocd -f https://raw.githubusercontent.com/argoproj/argo-cd/stable/manifests/install.yaml</span></span></code></pre></div>
</div>
<p>This will create a namespace within your cluster and Argo CD services and applications.</p>
<h3 id="access-argo-cd-api-server">Access Argo CD API Server</h3>
<p>By default, Argo CD API server is not exposed to external IP for security reasons. For this tutorial, we will access
the server using port forwarding. Kubectl port-forwarding is used to connect to the API server without
exposing the service to the outside. We will use the same method for our example application.</p>
<div class="code-block">
  <div class="code-block-header">
    <span class="code-block-lang">bash</span>
    <button type="button" class="code-copy" aria-label="Copy code">
      <span class="code-copy-icon"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><rect x="9" y="9" width="13" height="13" rx="2" ry="2"/><path d="M5 15H4a2 2 0 01-2-2V4a2 2 0 012-2h9a2 2 0 012 2v1"/></svg>
</span>
      <span class="code-copy-icon code-copy-icon-check"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><polyline points="20 6 9 17 4 12"/></svg>
</span>
      <span class="code-copy-label">Copy</span>
    </button>
  </div>
  <div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="cl">kubectl port-forward svc/argocd-server -n argocd 8080:443</span></span></code></pre></div>
</div>
<p>This will expose Argo CD service to <a href="http://localhost:8080" target="_blank" rel="noopener noreferrer">localhost:8080</a>
.</p>
<h3 id="login-to-argo-cd-in-console">Login to Argo CD in console</h3>
<p>Before we access the Argo CD UI, we need to get and change the admin password. Let&rsquo;s list all the pods of <code>argocd</code>
namespace.</p>
<div class="code-block">
  <div class="code-block-header">
    <span class="code-block-lang">bash</span>
    <button type="button" class="code-copy" aria-label="Copy code">
      <span class="code-copy-icon"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><rect x="9" y="9" width="13" height="13" rx="2" ry="2"/><path d="M5 15H4a2 2 0 01-2-2V4a2 2 0 012-2h9a2 2 0 012 2v1"/></svg>
</span>
      <span class="code-copy-icon code-copy-icon-check"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><polyline points="20 6 9 17 4 12"/></svg>
</span>
      <span class="code-copy-label">Copy</span>
    </button>
  </div>
  <div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="cl">kubectl get pods -n argocd <span class="p">|</span> grep argocd-server</span></span></code></pre></div>
</div>
<p>This will output Argo CD server pod details. The name of the pod is our first password for the admin user. Once you
request to
login, it will ask for username and password. Use <code>admin</code> for username, the pod&rsquo;s name for the password.</p>
<div class="code-block">
  <div class="code-block-header">
    <span class="code-block-lang">bash</span>
    <button type="button" class="code-copy" aria-label="Copy code">
      <span class="code-copy-icon"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><rect x="9" y="9" width="13" height="13" rx="2" ry="2"/><path d="M5 15H4a2 2 0 01-2-2V4a2 2 0 012-2h9a2 2 0 012 2v1"/></svg>
</span>
      <span class="code-copy-icon code-copy-icon-check"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><polyline points="20 6 9 17 4 12"/></svg>
</span>
      <span class="code-copy-label">Copy</span>
    </button>
  </div>
  <div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="cl">argocd login localhost:8080</span></span></code></pre></div>
</div>
<h3 id="change-argo-cd-admin-user-password">Change Argo CD Admin User Password</h3>
<p>Once you logged in, change the password for the admin user with the following command. You will be asked to enter the
old password and new password.</p>
<div class="code-block">
  <div class="code-block-header">
    <span class="code-block-lang">bash</span>
    <button type="button" class="code-copy" aria-label="Copy code">
      <span class="code-copy-icon"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><rect x="9" y="9" width="13" height="13" rx="2" ry="2"/><path d="M5 15H4a2 2 0 01-2-2V4a2 2 0 012-2h9a2 2 0 012 2v1"/></svg>
</span>
      <span class="code-copy-icon code-copy-icon-check"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><polyline points="20 6 9 17 4 12"/></svg>
</span>
      <span class="code-copy-label">Copy</span>
    </button>
  </div>
  <div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="cl">argocd account update-password</span></span></code></pre></div>
</div>
<h3 id="login-argo-cd-ui">Login Argo CD UI</h3>
<p>Now, let&rsquo;s go to <a href="http://localhost:8080" target="_blank" rel="noopener noreferrer">localhost:8080</a>
 to access the UI.</p>
<h3 id="create-a-github-repository">Create a Github Repository</h3>
<p>As we mentioned in the introduction, Argo CD follows GitOps flow. We will need a repository where we can store our
application manifest files. Let&rsquo;s create a repository and put the following <code>deployment</code>, <code>service</code>, and <code>argocd application</code>
YAML files into it. For this tutorial, I used <a href="https://github.com/omegion/echo-k8s-app/" target="_blank" rel="noopener noreferrer">example repository</a>
.</p>
<h3 id="deployment">Deployment</h3>
<p>Create <code>deployment.yaml</code> file with following content.</p>
<div class="code-block">
  <div class="code-block-header">
    <span class="code-block-lang">yaml</span>
    <button type="button" class="code-copy" aria-label="Copy code">
      <span class="code-copy-icon"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><rect x="9" y="9" width="13" height="13" rx="2" ry="2"/><path d="M5 15H4a2 2 0 01-2-2V4a2 2 0 012-2h9a2 2 0 012 2v1"/></svg>
</span>
      <span class="code-copy-icon code-copy-icon-check"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><polyline points="20 6 9 17 4 12"/></svg>
</span>
      <span class="code-copy-label">Copy</span>
    </button>
  </div>
  <div class="highlight"><pre tabindex="0" class="chroma"><code class="language-yaml" data-lang="yaml"><span class="line"><span class="cl"><span class="nt">apiVersion</span><span class="p">:</span><span class="w"> </span><span class="l">apps/v1</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="nt">kind</span><span class="p">:</span><span class="w"> </span><span class="l">Deployment</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="nt">metadata</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">  </span><span class="nt">name</span><span class="p">:</span><span class="w"> </span><span class="l">echo-deployment</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="nt">spec</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">  </span><span class="nt">replicas</span><span class="p">:</span><span class="w"> </span><span class="m">1</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">  </span><span class="nt">selector</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">matchLabels</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span><span class="nt">app</span><span class="p">:</span><span class="w"> </span><span class="l">echo-server</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">  </span><span class="nt">template</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">metadata</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span><span class="nt">labels</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">        </span><span class="nt">app</span><span class="p">:</span><span class="w"> </span><span class="l">echo-server</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">spec</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span><span class="nt">containers</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">        </span>- <span class="nt">name</span><span class="p">:</span><span class="w"> </span><span class="l">echo-server</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">          </span><span class="nt">image</span><span class="p">:</span><span class="w"> </span><span class="l">jmalloc/echo-server</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">          </span><span class="nt">ports</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">            </span>- <span class="nt">name</span><span class="p">:</span><span class="w"> </span><span class="l">http-port</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">              </span><span class="nt">containerPort</span><span class="p">:</span><span class="w"> </span><span class="m">8080</span></span></span></code></pre></div>
</div>
<h3 id="service">Service</h3>
<p>Create <code>service.yaml</code> file with following content.</p>
<div class="code-block">
  <div class="code-block-header">
    <span class="code-block-lang">yaml</span>
    <button type="button" class="code-copy" aria-label="Copy code">
      <span class="code-copy-icon"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><rect x="9" y="9" width="13" height="13" rx="2" ry="2"/><path d="M5 15H4a2 2 0 01-2-2V4a2 2 0 012-2h9a2 2 0 012 2v1"/></svg>
</span>
      <span class="code-copy-icon code-copy-icon-check"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><polyline points="20 6 9 17 4 12"/></svg>
</span>
      <span class="code-copy-label">Copy</span>
    </button>
  </div>
  <div class="highlight"><pre tabindex="0" class="chroma"><code class="language-yaml" data-lang="yaml"><span class="line"><span class="cl"><span class="nt">apiVersion</span><span class="p">:</span><span class="w"> </span><span class="l">v1</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="nt">kind</span><span class="p">:</span><span class="w"> </span><span class="l">Service</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="nt">metadata</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">  </span><span class="nt">name</span><span class="p">:</span><span class="w"> </span><span class="l">echo-server-service</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="nt">spec</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">  </span><span class="nt">ports</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span>- <span class="nt">name</span><span class="p">:</span><span class="w"> </span><span class="l">app</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span><span class="nt">port</span><span class="p">:</span><span class="w"> </span><span class="m">80</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span><span class="nt">targetPort</span><span class="p">:</span><span class="w"> </span><span class="m">8080</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">  </span><span class="nt">selector</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">app</span><span class="p">:</span><span class="w"> </span><span class="l">echo-server</span></span></span></code></pre></div>
</div>
<h3 id="argo-cd-application">Argo CD Application</h3>
<p>Create <code>argocd-app.yaml</code> file with following content.</p>
<div class="code-block">
  <div class="code-block-header">
    <span class="code-block-lang">yaml</span>
    <button type="button" class="code-copy" aria-label="Copy code">
      <span class="code-copy-icon"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><rect x="9" y="9" width="13" height="13" rx="2" ry="2"/><path d="M5 15H4a2 2 0 01-2-2V4a2 2 0 012-2h9a2 2 0 012 2v1"/></svg>
</span>
      <span class="code-copy-icon code-copy-icon-check"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><polyline points="20 6 9 17 4 12"/></svg>
</span>
      <span class="code-copy-label">Copy</span>
    </button>
  </div>
  <div class="highlight"><pre tabindex="0" class="chroma"><code class="language-yaml" data-lang="yaml"><span class="line"><span class="cl"><span class="nt">apiVersion</span><span class="p">:</span><span class="w"> </span><span class="l">argoproj.io/v1alpha1</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="nt">kind</span><span class="p">:</span><span class="w"> </span><span class="l">Application</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="nt">metadata</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">  </span><span class="nt">name</span><span class="p">:</span><span class="w"> </span><span class="l">echo-server</span><span class="w"> </span><span class="c"># name of the Argo CD application</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">  </span><span class="nt">namespace</span><span class="p">:</span><span class="w"> </span><span class="l">argocd</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="nt">spec</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">  </span><span class="nt">project</span><span class="p">:</span><span class="w"> </span><span class="l">default</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">  </span><span class="nt">source</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">repoURL</span><span class="p">:</span><span class="w"> </span><span class="l">https://github.com/omegion/echo-k8s-app/</span><span class="w"> </span><span class="c"># change here with your repository url</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">targetRevision</span><span class="p">:</span><span class="w"> </span><span class="l">HEAD</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">path</span><span class="p">:</span><span class="w"> </span><span class="l">kubernetes</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">directory</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span><span class="nt">recurse</span><span class="p">:</span><span class="w"> </span><span class="kc">true</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">  </span><span class="nt">destination</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">server</span><span class="p">:</span><span class="w"> </span><span class="l">https://kubernetes.default.svc</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">namespace</span><span class="p">:</span><span class="w"> </span><span class="l">echo-server</span><span class="w"> </span><span class="c"># namespace we created</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">  </span><span class="nt">syncPolicy</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">automated</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span><span class="nt">prune</span><span class="p">:</span><span class="w"> </span><span class="kc">false</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span><span class="nt">selfHeal</span><span class="p">:</span><span class="w"> </span><span class="kc">false</span></span></span></code></pre></div>
</div>
<p>After creating these files, push them to the repository. You can change <code>repoURL</code> with the created repository. I used
an example repository for this tutorial.</p>
<h2 id="deploy-argo-cd-application">Deploy Argo CD Application</h2>
<p>Once we have all three files, let&rsquo;s apply the Argo CD Application YAML file with <code>kubectl</code>.</p>
<div class="code-block">
  <div class="code-block-header">
    <span class="code-block-lang">sh</span>
    <button type="button" class="code-copy" aria-label="Copy code">
      <span class="code-copy-icon"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><rect x="9" y="9" width="13" height="13" rx="2" ry="2"/><path d="M5 15H4a2 2 0 01-2-2V4a2 2 0 012-2h9a2 2 0 012 2v1"/></svg>
</span>
      <span class="code-copy-icon code-copy-icon-check"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><polyline points="20 6 9 17 4 12"/></svg>
</span>
      <span class="code-copy-label">Copy</span>
    </button>
  </div>
  <div class="highlight"><pre tabindex="0" class="chroma"><code class="language-sh" data-lang="sh"><span class="line"><span class="cl">kubectl apply -f argocd-app.yaml</span></span></code></pre></div>
</div>
<p>This will create an application in Argo CD UI.</p>
<p><img src="/2021/06/how-to-install-argocd-on-kubernetes-cluster/images/argo-cd-application-screen-1.png" alt="image" title="Argo CD Application Screen"></p>
<h2 id="access-argo-cd-application-with-port-forward">Access Argo CD Application with Port Forward</h2>
<p>In order to access an application from outside in the cluster, we would need an ingress. However, we didn&rsquo;t create
ingress to access our application from outside in this tutorial. To access our example application, we need to use
port-forwarding as we did for <code>argocd-server</code>.</p>
<div class="code-block">
  <div class="code-block-header">
    <span class="code-block-lang">sh</span>
    <button type="button" class="code-copy" aria-label="Copy code">
      <span class="code-copy-icon"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><rect x="9" y="9" width="13" height="13" rx="2" ry="2"/><path d="M5 15H4a2 2 0 01-2-2V4a2 2 0 012-2h9a2 2 0 012 2v1"/></svg>
</span>
      <span class="code-copy-icon code-copy-icon-check"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><polyline points="20 6 9 17 4 12"/></svg>
</span>
      <span class="code-copy-label">Copy</span>
    </button>
  </div>
  <div class="highlight"><pre tabindex="0" class="chroma"><code class="language-sh" data-lang="sh"><span class="line"><span class="cl">kubectl port-forward svc/echo-server-service -n echo-server 8001:80</span></span></code></pre></div>
</div>
<p>This will create a tunnel between your local port and the application. Visit <a href="http://localhost:8001" target="_blank" rel="noopener noreferrer">localhost:8001</a>

on your browser. You will see the request details.</p>
<div class="code-block">
  <div class="code-block-header">
    <span class="code-block-lang">text</span>
    <button type="button" class="code-copy" aria-label="Copy code">
      <span class="code-copy-icon"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><rect x="9" y="9" width="13" height="13" rx="2" ry="2"/><path d="M5 15H4a2 2 0 01-2-2V4a2 2 0 012-2h9a2 2 0 012 2v1"/></svg>
</span>
      <span class="code-copy-icon code-copy-icon-check"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><polyline points="20 6 9 17 4 12"/></svg>
</span>
      <span class="code-copy-label">Copy</span>
    </button>
  </div>
  <div class="highlight"><pre tabindex="0" class="chroma"><code class="language-text" data-lang="text"><span class="line"><span class="cl">Request served by echo-deployment-788c68557d-72fgl
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl">HTTP/1.1 GET /
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl">Host: localhost:8001
</span></span><span class="line"><span class="cl">Accept-Language: en-GB,en-US;q=0.9,en;q=0.8,tr;q=0.7
</span></span><span class="line"><span class="cl">Cache-Control: max-age=0
</span></span><span class="line"><span class="cl">Upgrade-Insecure-Requests: 1
</span></span><span class="line"><span class="cl">User-Agent: Mozilla/5.0 (Macintosh; Intel Mac OS X 10_14_6) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/83.0.4103.116 Safari/537.36
</span></span><span class="line"><span class="cl">Sec-Fetch-User: ?1
</span></span><span class="line"><span class="cl">Sec-Fetch-Dest: document
</span></span><span class="line"><span class="cl">Connection: keep-alive
</span></span><span class="line"><span class="cl">Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.9
</span></span><span class="line"><span class="cl">Sec-Fetch-Site: none
</span></span><span class="line"><span class="cl">Sec-Fetch-Mode: navigate
</span></span><span class="line"><span class="cl">Accept-Encoding: gzip, deflate, br</span></span></code></pre></div>
</div>
<h2 id="conclusion">Conclusion</h2>
<p>In this tutorial, we installed Argo CD into our existing cluster and created a simple application. Since Argo CD
follows GitOps flow, we needed to create a repository from that the Argo CD application can read the manifest files.
Finally, we connect to our application with port-forward through application service and test the echo server
application.</p>
]]></content:encoded></item><item><title>How to automatically unseal Vault Server for free</title><link>https://omegion.dev/2021/04/how-to-automatically-unseal-vault-server-for-free/</link><pubDate>Wed, 07 Apr 2021 00:00:00 +0000</pubDate><guid>https://omegion.dev/2021/04/how-to-automatically-unseal-vault-server-for-free/</guid><description>A tool to automatically unseal Vault Server for free.</description><content:encoded><![CDATA[<h2 id="introduction">Introduction</h2>
<p>Vault is a very powerful secret manager tool from Hashicorp. It is widely used
for a different platform to store secrets. I run my Vault server in my
Kubernetes cluster deployed with Helm Chart. When the pod restarted, the Vault
will seal itself. You will need to unseal it to use. Vault introduces different
alternatives to unseal it automatically. However, all of them require an extra
fee. For this reason, I created a CLI tool to unseal your Vault server. In this
tutorial, I will show you how to create a CronJob within a Kubernetes cluster to
unseal your Vault server for free.</p>
<h2 id="prerequisites">Prerequisites</h2>
<p>Before you get started, you will need to have a number of things.</p>
<ol>
<li>A running Kubernetes cluster.</li>
<li><code>kubectl</code> installed. If not, you will need it to manage your clusters.</li>
<li>Have a <code>KUBECONFIG</code> file (default location is ~/.kube/config).</li>
<li>Helm v3.</li>
</ol>
<h2 id="vault-unseal-cli">Vault Unseal CLI</h2>
<p>Vault Unseal is a tool to allow you to unseal your Vault server in command line.
It accepts <code>address</code> and <code>shard</code> parameters. If you Vault is sealed, it will use
provided shards to unseal it. If it is unsealed already, it will simply do
nothing.</p>
<div class="code-block">
  <div class="code-block-header">
    <span class="code-block-lang">bash</span>
    <button type="button" class="code-copy" aria-label="Copy code">
      <span class="code-copy-icon"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><rect x="9" y="9" width="13" height="13" rx="2" ry="2"/><path d="M5 15H4a2 2 0 01-2-2V4a2 2 0 012-2h9a2 2 0 012 2v1"/></svg>
</span>
      <span class="code-copy-icon code-copy-icon-check"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><polyline points="20 6 9 17 4 12"/></svg>
</span>
      <span class="code-copy-label">Copy</span>
    </button>
  </div>
  <div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="cl">CLI <span class="nb">command</span> to automatically unseal Vault
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl">Usage:
</span></span><span class="line"><span class="cl">  vault-unseal <span class="o">[</span>command<span class="o">]</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl">Available Commands:
</span></span><span class="line"><span class="cl">  <span class="nb">help</span>        Help about any <span class="nb">command</span>
</span></span><span class="line"><span class="cl">  unseal      Unseal Vault.
</span></span><span class="line"><span class="cl">  version     Print the version/build number
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl">Flags:
</span></span><span class="line"><span class="cl">  -h, --help   <span class="nb">help</span> <span class="k">for</span> vault-unseal
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl">Use <span class="s2">&#34;vault-unseal [command] --help&#34;</span> <span class="k">for</span> more information about a command.</span></span></code></pre></div>
</div>
<h2 id="helm-repository">Helm Repository</h2>
<p>We will use Vault Unseal helm chart to create Kubernetes Cron Job. For this,
let&rsquo;s add Helm Chart repository.</p>
<div class="code-block">
  <div class="code-block-header">
    <span class="code-block-lang">bash</span>
    <button type="button" class="code-copy" aria-label="Copy code">
      <span class="code-copy-icon"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><rect x="9" y="9" width="13" height="13" rx="2" ry="2"/><path d="M5 15H4a2 2 0 01-2-2V4a2 2 0 012-2h9a2 2 0 012 2v1"/></svg>
</span>
      <span class="code-copy-icon code-copy-icon-check"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><polyline points="20 6 9 17 4 12"/></svg>
</span>
      <span class="code-copy-label">Copy</span>
    </button>
  </div>
  <div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="cl">helm repo add omegion https://charts.omegion.dev
</span></span><span class="line"><span class="cl">helm repo update</span></span></code></pre></div>
</div>
<p>Now, we have helm charts, let&rsquo;s look up the one that we want to install.</p>
<div class="code-block">
  <div class="code-block-header">
    <span class="code-block-lang">bash</span>
    <button type="button" class="code-copy" aria-label="Copy code">
      <span class="code-copy-icon"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><rect x="9" y="9" width="13" height="13" rx="2" ry="2"/><path d="M5 15H4a2 2 0 01-2-2V4a2 2 0 012-2h9a2 2 0 012 2v1"/></svg>
</span>
      <span class="code-copy-icon code-copy-icon-check"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><polyline points="20 6 9 17 4 12"/></svg>
</span>
      <span class="code-copy-label">Copy</span>
    </button>
  </div>
  <div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="cl">❯ helm search repo omegion/vault-unseal-cronjob
</span></span><span class="line"><span class="cl">NAME                        	CHART VERSION	APP VERSION	DESCRIPTION
</span></span><span class="line"><span class="cl">omegion/vault-unseal-cronjob	0.3.0        	0.3.0      	A chart <span class="k">for</span> unseal Vault cron job</span></span></code></pre></div>
</div>
<p>You can see the cronjob chart. Let&rsquo;s install it on our cluster.</p>
<h2 id="helm-chart-install">Helm Chart Install</h2>
<p>Before we install our Vault Unseal helm chart, let&rsquo;s create <code>values.yaml</code> file
which will contain Vault server details for the job.</p>
<div class="code-block">
  <div class="code-block-header">
    <span class="code-block-lang">yaml</span>
    <button type="button" class="code-copy" aria-label="Copy code">
      <span class="code-copy-icon"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><rect x="9" y="9" width="13" height="13" rx="2" ry="2"/><path d="M5 15H4a2 2 0 01-2-2V4a2 2 0 012-2h9a2 2 0 012 2v1"/></svg>
</span>
      <span class="code-copy-icon code-copy-icon-check"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><polyline points="20 6 9 17 4 12"/></svg>
</span>
      <span class="code-copy-label">Copy</span>
    </button>
  </div>
  <div class="highlight"><pre tabindex="0" class="chroma"><code class="language-yaml" data-lang="yaml"><span class="line"><span class="cl"><span class="nt">schedule</span><span class="p">:</span><span class="w"> </span><span class="s1">&#39;&#34;*/30 * * * *&#34;&#39;</span><span class="w"> </span><span class="c"># Runs every 30 mins.</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="nt">vault</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">  </span><span class="nt">address</span><span class="p">:</span><span class="w"> </span><span class="l">https://vault.mydomain.com</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">  </span><span class="nt">shards</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span>- <span class="s2">&#34;SHARD_1&#34;</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span>- <span class="s2">&#34;SHARD_2&#34;</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span>- <span class="s2">&#34;SHARD_3&#34;</span></span></span></code></pre></div>
</div>
<p>Once we have <code>values.yaml</code> file created and details are provided, let&rsquo;s install
the helm chart with it.</p>
<div class="code-block">
  <div class="code-block-header">
    <span class="code-block-lang">bash</span>
    <button type="button" class="code-copy" aria-label="Copy code">
      <span class="code-copy-icon"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><rect x="9" y="9" width="13" height="13" rx="2" ry="2"/><path d="M5 15H4a2 2 0 01-2-2V4a2 2 0 012-2h9a2 2 0 012 2v1"/></svg>
</span>
      <span class="code-copy-icon code-copy-icon-check"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><polyline points="20 6 9 17 4 12"/></svg>
</span>
      <span class="code-copy-label">Copy</span>
    </button>
  </div>
  <div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="cl">❯ helm install vault-unseal-cronjob omegion/vault-unseal-cronjob --values values.yaml
</span></span><span class="line"><span class="cl">NAME: vault-unseal-cronjob
</span></span><span class="line"><span class="cl">LAST DEPLOYED: Wed Apr  <span class="m">7</span> 20:42:17 <span class="m">2021</span>
</span></span><span class="line"><span class="cl">NAMESPACE: default
</span></span><span class="line"><span class="cl">STATUS: deployed
</span></span><span class="line"><span class="cl">REVISION: <span class="m">1</span>
</span></span><span class="line"><span class="cl">TEST SUITE: None</span></span></code></pre></div>
</div>
<p>We successfully deployed the job, let&rsquo;s check it with <code>kubectl</code>:</p>
<div class="code-block">
  <div class="code-block-header">
    <span class="code-block-lang">bash</span>
    <button type="button" class="code-copy" aria-label="Copy code">
      <span class="code-copy-icon"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><rect x="9" y="9" width="13" height="13" rx="2" ry="2"/><path d="M5 15H4a2 2 0 01-2-2V4a2 2 0 012-2h9a2 2 0 012 2v1"/></svg>
</span>
      <span class="code-copy-icon code-copy-icon-check"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><polyline points="20 6 9 17 4 12"/></svg>
</span>
      <span class="code-copy-label">Copy</span>
    </button>
  </div>
  <div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="cl">❯ kubectl get cronjob vault-unseal-cronjob
</span></span><span class="line"><span class="cl">NAME                   SCHEDULE       SUSPEND   ACTIVE   LAST SCHEDULE   AGE
</span></span><span class="line"><span class="cl">vault-unseal-cronjob   */30 * * * *   False     <span class="m">0</span>        &lt;none&gt;          54s</span></span></code></pre></div>
</div>
<h2 id="test-cron-job">Test Cron Job</h2>
<p>The job will run every <code>30</code> minutes. Let&rsquo;s create a job from the cronjob that we
created to test it.</p>
<div class="code-block">
  <div class="code-block-header">
    <span class="code-block-lang">bash</span>
    <button type="button" class="code-copy" aria-label="Copy code">
      <span class="code-copy-icon"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><rect x="9" y="9" width="13" height="13" rx="2" ry="2"/><path d="M5 15H4a2 2 0 01-2-2V4a2 2 0 012-2h9a2 2 0 012 2v1"/></svg>
</span>
      <span class="code-copy-icon code-copy-icon-check"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><polyline points="20 6 9 17 4 12"/></svg>
</span>
      <span class="code-copy-label">Copy</span>
    </button>
  </div>
  <div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="cl">❯ kubectl create job --from<span class="o">=</span>cronjob/vault-unseal-cronjob manual-unseal-job
</span></span><span class="line"><span class="cl">job.batch/manual-unseal-job created</span></span></code></pre></div>
</div>
<p>Let&rsquo;s see the logs of the job that we created.</p>
<div class="code-block">
  <div class="code-block-header">
    <span class="code-block-lang">bash</span>
    <button type="button" class="code-copy" aria-label="Copy code">
      <span class="code-copy-icon"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><rect x="9" y="9" width="13" height="13" rx="2" ry="2"/><path d="M5 15H4a2 2 0 01-2-2V4a2 2 0 012-2h9a2 2 0 012 2v1"/></svg>
</span>
      <span class="code-copy-icon code-copy-icon-check"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><polyline points="20 6 9 17 4 12"/></svg>
</span>
      <span class="code-copy-label">Copy</span>
    </button>
  </div>
  <div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="cl">❯ kubectl get pods --selector<span class="o">=</span>job-name<span class="o">=</span>manual-unseal-job
</span></span><span class="line"><span class="cl">NAME                      READY   STATUS      RESTARTS   AGE
</span></span><span class="line"><span class="cl">manual-unseal-job-bsjwv   0/1     Completed   <span class="m">0</span>          102s
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl">❯ kubectl logs manual-unseal-job-bsjwv                    
</span></span><span class="line"><span class="cl">It is unsealed.</span></span></code></pre></div>
</div>
<p>As my Vault server is already unsealed, the job logged that it is unsealed.</p>
<h2 id="conclusion">Conclusion</h2>
<p>Self-managing the Vault server on a Kubernetes cluster is hard. It can get
sealed if the pod recreated. We used the Vault Unsealed CLI tool with Kubernetes
CronJob to automatically unseal it. It is not a perfect solution, however, it is
the only free way to keep your Vault server unsealed.</p>
]]></content:encoded></item><item><title>Vault Signed SSH Certificate Manager</title><link>https://omegion.dev/projects/vault-ssh-certifiacate-manager/</link><pubDate>Thu, 01 Apr 2021 00:00:00 +0000</pubDate><guid>https://omegion.dev/projects/vault-ssh-certifiacate-manager/</guid><description>Vault Signed SSH Certificate Manager for servers.</description><content:encoded></content:encoded></item><item><title>How to deploy Nuxt JS on Kubernetes Cluster (Part 2)</title><link>https://omegion.dev/2021/03/how-to-deploy-nuxt-js-on-kubernetes-cluster-part-2/</link><pubDate>Sat, 20 Mar 2021 00:00:00 +0000</pubDate><guid>https://omegion.dev/2021/03/how-to-deploy-nuxt-js-on-kubernetes-cluster-part-2/</guid><description>A tutorial about deploying Nuxt.js application on Kubernetes cluster in minutes.</description><content:encoded><![CDATA[<h2 id="introduction">Introduction</h2>
<p>In the previous tutorial, I explain how to dockerize Nuxt.js application. Now, we are good to deploy this docker image on a Kubernetes cluster.</p>
<h2 id="prerequisites">Prerequisites</h2>
<p>Before you get started, you will need to have a number of things.</p>
<ol>
<li>Running Kubernetes cluster.</li>
</ol>
<h2 id="pushing-docker-image">Pushing Docker Image</h2>
<p>We built our docker image on the previous tutorial, now let&rsquo;s tag it and push it to Docker Hub. My Docker Hub username is <code>omegion</code>, so you should use your name instead.</p>
<div class="code-block">
  <div class="code-block-header">
    <span class="code-block-lang">bash</span>
    <button type="button" class="code-copy" aria-label="Copy code">
      <span class="code-copy-icon"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><rect x="9" y="9" width="13" height="13" rx="2" ry="2"/><path d="M5 15H4a2 2 0 01-2-2V4a2 2 0 012-2h9a2 2 0 012 2v1"/></svg>
</span>
      <span class="code-copy-icon code-copy-icon-check"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><polyline points="20 6 9 17 4 12"/></svg>
</span>
      <span class="code-copy-label">Copy</span>
    </button>
  </div>
  <div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="cl"><span class="nv">DOCKER_USER</span><span class="o">=</span>omegion
</span></span><span class="line"><span class="cl">docker tag nuxt-k8s:latest <span class="nv">$DOCKER_USER</span>/nuxt-k8s:latest
</span></span><span class="line"><span class="cl">docker push <span class="nv">$DOCKER_USER</span>/nuxt-k8s</span></span></code></pre></div>
</div>
<h2 id="deployment">Deployment</h2>
<p>Deployment is a declaration of Kubernetes Pod where we run our docker image on the cluster. We describe ReplicaSet, and container parameters here.</p>
<p>The first step for our Nuxt.js application on Kubernetes is creating a deployment. Again, I used <code>omegion</code> username to refer to my image that I pushed on the previous tutorial.</p>
<p>Create <code>deployment.yaml</code>:</p>
<div class="code-block">
  <div class="code-block-header">
    <span class="code-block-lang">yaml</span>
    <button type="button" class="code-copy" aria-label="Copy code">
      <span class="code-copy-icon"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><rect x="9" y="9" width="13" height="13" rx="2" ry="2"/><path d="M5 15H4a2 2 0 01-2-2V4a2 2 0 012-2h9a2 2 0 012 2v1"/></svg>
</span>
      <span class="code-copy-icon code-copy-icon-check"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><polyline points="20 6 9 17 4 12"/></svg>
</span>
      <span class="code-copy-label">Copy</span>
    </button>
  </div>
  <div class="highlight"><pre tabindex="0" class="chroma"><code class="language-yaml" data-lang="yaml"><span class="line"><span class="cl"><span class="nt">apiVersion</span><span class="p">:</span><span class="w"> </span><span class="l">apps/v1</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="nt">kind</span><span class="p">:</span><span class="w"> </span><span class="l">Deployment</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="nt">metadata</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">  </span><span class="nt">name</span><span class="p">:</span><span class="w"> </span><span class="l">nuxt-k8s</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="nt">spec</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">  </span><span class="nt">replicas</span><span class="p">:</span><span class="w"> </span><span class="m">1</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">  </span><span class="nt">selector</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">matchLabels</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span><span class="nt">app</span><span class="p">:</span><span class="w"> </span><span class="l">nuxt-k8s</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">  </span><span class="nt">template</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">metadata</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span><span class="nt">labels</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">        </span><span class="nt">app</span><span class="p">:</span><span class="w"> </span><span class="l">nuxt-k8s</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">spec</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span><span class="nt">containers</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">        </span>- <span class="nt">name</span><span class="p">:</span><span class="w"> </span><span class="l">nuxt-k8s</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">          </span><span class="nt">image</span><span class="p">:</span><span class="w"> </span><span class="l">omegion/nuxt-k8s:latest</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">          </span><span class="nt">ports</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">            </span>- <span class="nt">containerPort</span><span class="p">:</span><span class="w"> </span><span class="m">80</span></span></span></code></pre></div>
</div>
<h3 id="applying-deployment">Applying Deployment</h3>
<p>Once we create <code>deployment.yaml</code> with Pod manifest, let&rsquo;s apply this on the cluster.</p>
<div class="code-block">
  <div class="code-block-header">
    <span class="code-block-lang">shell</span>
    <button type="button" class="code-copy" aria-label="Copy code">
      <span class="code-copy-icon"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><rect x="9" y="9" width="13" height="13" rx="2" ry="2"/><path d="M5 15H4a2 2 0 01-2-2V4a2 2 0 012-2h9a2 2 0 012 2v1"/></svg>
</span>
      <span class="code-copy-icon code-copy-icon-check"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><polyline points="20 6 9 17 4 12"/></svg>
</span>
      <span class="code-copy-label">Copy</span>
    </button>
  </div>
  <div class="highlight"><pre tabindex="0" class="chroma"><code class="language-shell" data-lang="shell"><span class="line"><span class="cl">kubectl apply -f deployment.yaml</span></span></code></pre></div>
</div>
<p>If everything goes well, it will print:</p>
<div class="code-block">
  <div class="code-block-header">
    <span class="code-block-lang">shell</span>
    <button type="button" class="code-copy" aria-label="Copy code">
      <span class="code-copy-icon"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><rect x="9" y="9" width="13" height="13" rx="2" ry="2"/><path d="M5 15H4a2 2 0 01-2-2V4a2 2 0 012-2h9a2 2 0 012 2v1"/></svg>
</span>
      <span class="code-copy-icon code-copy-icon-check"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><polyline points="20 6 9 17 4 12"/></svg>
</span>
      <span class="code-copy-label">Copy</span>
    </button>
  </div>
  <div class="highlight"><pre tabindex="0" class="chroma"><code class="language-shell" data-lang="shell"><span class="line"><span class="cl">deployment.apps/nuxt-k8s created</span></span></code></pre></div>
</div>
<p>Now, let&rsquo;s check the status of the deployment that we just created.</p>
<div class="code-block">
  <div class="code-block-header">
    <span class="code-block-lang">shell</span>
    <button type="button" class="code-copy" aria-label="Copy code">
      <span class="code-copy-icon"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><rect x="9" y="9" width="13" height="13" rx="2" ry="2"/><path d="M5 15H4a2 2 0 01-2-2V4a2 2 0 012-2h9a2 2 0 012 2v1"/></svg>
</span>
      <span class="code-copy-icon code-copy-icon-check"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><polyline points="20 6 9 17 4 12"/></svg>
</span>
      <span class="code-copy-label">Copy</span>
    </button>
  </div>
  <div class="highlight"><pre tabindex="0" class="chroma"><code class="language-shell" data-lang="shell"><span class="line"><span class="cl">❯ kubectl get deployment nuxt-k8s           
</span></span><span class="line"><span class="cl">NAME       READY   UP-TO-DATE   AVAILABLE   AGE
</span></span><span class="line"><span class="cl">nuxt-k8s   1/1     <span class="m">1</span>            <span class="m">1</span>           86s</span></span></code></pre></div>
</div>
<h2 id="service">Service</h2>
<p>A service is a network layer of Kubernetes Pod that allows us to communicate our pod running on the cluster. Without a <code>Service</code> we cannot connect to our Pod which runs a docker image of our application from the outside world.</p>
<p>As we exposed port <code>80</code> for our docker image, therefore, we assigned the <code>port</code> of the service to <code>80</code>.</p>
<p>Create <code>service.yaml</code>:</p>
<div class="code-block">
  <div class="code-block-header">
    <span class="code-block-lang">yaml</span>
    <button type="button" class="code-copy" aria-label="Copy code">
      <span class="code-copy-icon"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><rect x="9" y="9" width="13" height="13" rx="2" ry="2"/><path d="M5 15H4a2 2 0 01-2-2V4a2 2 0 012-2h9a2 2 0 012 2v1"/></svg>
</span>
      <span class="code-copy-icon code-copy-icon-check"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><polyline points="20 6 9 17 4 12"/></svg>
</span>
      <span class="code-copy-label">Copy</span>
    </button>
  </div>
  <div class="highlight"><pre tabindex="0" class="chroma"><code class="language-yaml" data-lang="yaml"><span class="line"><span class="cl"><span class="nt">apiVersion</span><span class="p">:</span><span class="w"> </span><span class="l">v1</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="nt">kind</span><span class="p">:</span><span class="w"> </span><span class="l">Service</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="nt">metadata</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">  </span><span class="nt">name</span><span class="p">:</span><span class="w"> </span><span class="l">nuxt-k8s-service</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="nt">spec</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">  </span><span class="nt">ports</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span>- <span class="nt">name</span><span class="p">:</span><span class="w"> </span><span class="l">app</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span><span class="nt">port</span><span class="p">:</span><span class="w"> </span><span class="m">80</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span><span class="nt">targetPort</span><span class="p">:</span><span class="w"> </span><span class="m">80</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">  </span><span class="nt">selector</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">app</span><span class="p">:</span><span class="w"> </span><span class="l">nuxt-k8s</span></span></span></code></pre></div>
</div>
<h3 id="applying-service">Applying Service</h3>
<p>Now, we have a Pod running our application on the cluster. We need to create a service to expose this Pod.</p>
<div class="code-block">
  <div class="code-block-header">
    <span class="code-block-lang">shell</span>
    <button type="button" class="code-copy" aria-label="Copy code">
      <span class="code-copy-icon"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><rect x="9" y="9" width="13" height="13" rx="2" ry="2"/><path d="M5 15H4a2 2 0 01-2-2V4a2 2 0 012-2h9a2 2 0 012 2v1"/></svg>
</span>
      <span class="code-copy-icon code-copy-icon-check"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><polyline points="20 6 9 17 4 12"/></svg>
</span>
      <span class="code-copy-label">Copy</span>
    </button>
  </div>
  <div class="highlight"><pre tabindex="0" class="chroma"><code class="language-shell" data-lang="shell"><span class="line"><span class="cl">❯ kubectl apply -f service.yaml   
</span></span><span class="line"><span class="cl">service/nuxt-k8s created</span></span></code></pre></div>
</div>
<p>Let&rsquo;s describe the status of the created service. If everything went well, you will see the description of the service we just created.</p>
<div class="code-block">
  <div class="code-block-header">
    <span class="code-block-lang">shell</span>
    <button type="button" class="code-copy" aria-label="Copy code">
      <span class="code-copy-icon"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><rect x="9" y="9" width="13" height="13" rx="2" ry="2"/><path d="M5 15H4a2 2 0 01-2-2V4a2 2 0 012-2h9a2 2 0 012 2v1"/></svg>
</span>
      <span class="code-copy-icon code-copy-icon-check"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><polyline points="20 6 9 17 4 12"/></svg>
</span>
      <span class="code-copy-label">Copy</span>
    </button>
  </div>
  <div class="highlight"><pre tabindex="0" class="chroma"><code class="language-shell" data-lang="shell"><span class="line"><span class="cl">❯ kubectl describe service/nuxt-k8s   
</span></span><span class="line"><span class="cl">Name:              nuxt-k8s
</span></span><span class="line"><span class="cl">Namespace:         default
</span></span><span class="line"><span class="cl">Labels:            &lt;none&gt;
</span></span><span class="line"><span class="cl">Annotations:       &lt;none&gt;
</span></span><span class="line"><span class="cl">Selector:          <span class="nv">app</span><span class="o">=</span>nuxt-k8s
</span></span><span class="line"><span class="cl">Type:              ClusterIP
</span></span><span class="line"><span class="cl">IP:                10.43.158.98
</span></span><span class="line"><span class="cl">Port:              app  80/TCP
</span></span><span class="line"><span class="cl">TargetPort:        80/TCP
</span></span><span class="line"><span class="cl">Endpoints:         10.42.1.33:80
</span></span><span class="line"><span class="cl">Session Affinity:  None
</span></span><span class="line"><span class="cl">Events:            &lt;none&gt;</span></span></code></pre></div>
</div>
<h2 id="accessing-to-the-application">Accessing to the Application</h2>
<p>We successfully created a deployment and service for our Nuxt.js application. Now, we&rsquo;re good to connect to our application running on the cluster.</p>
<h3 id="get-pod-name">Get Pod Name</h3>
<p>In order to access the application, we need to know the Pod name that is created with <code>Deployment</code>. Kubernetes defines a unique name for each Pod. Let&rsquo;s figure out what is the name of our Pod.</p>
<div class="code-block">
  <div class="code-block-header">
    <span class="code-block-lang">shell</span>
    <button type="button" class="code-copy" aria-label="Copy code">
      <span class="code-copy-icon"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><rect x="9" y="9" width="13" height="13" rx="2" ry="2"/><path d="M5 15H4a2 2 0 01-2-2V4a2 2 0 012-2h9a2 2 0 012 2v1"/></svg>
</span>
      <span class="code-copy-icon code-copy-icon-check"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><polyline points="20 6 9 17 4 12"/></svg>
</span>
      <span class="code-copy-label">Copy</span>
    </button>
  </div>
  <div class="highlight"><pre tabindex="0" class="chroma"><code class="language-shell" data-lang="shell"><span class="line"><span class="cl">❯ kubectl get pods --selector<span class="o">=</span><span class="nv">app</span><span class="o">=</span>nuxt-k8s
</span></span><span class="line"><span class="cl">NAME                        READY   STATUS    RESTARTS   AGE
</span></span><span class="line"><span class="cl">nuxt-k8s-6f797f85d5-dmcb6   1/1     Running   <span class="m">0</span>          7m52s</span></span></code></pre></div>
</div>
<p>As we see above, the Pod&rsquo;s name is <code>nuxt-k8s-6f797f85d5-dmcb6</code>. We will use this name to connect to the pod.</p>
<h3 id="port-forward-to-the-pod">Port Forward to the Pod</h3>
<p>We will use Pod&rsquo;s name to create <code>port-forward</code> through the service that we can access the application from local.</p>
<div class="code-block">
  <div class="code-block-header">
    <span class="code-block-lang">shell</span>
    <button type="button" class="code-copy" aria-label="Copy code">
      <span class="code-copy-icon"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><rect x="9" y="9" width="13" height="13" rx="2" ry="2"/><path d="M5 15H4a2 2 0 01-2-2V4a2 2 0 012-2h9a2 2 0 012 2v1"/></svg>
</span>
      <span class="code-copy-icon code-copy-icon-check"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><polyline points="20 6 9 17 4 12"/></svg>
</span>
      <span class="code-copy-label">Copy</span>
    </button>
  </div>
  <div class="highlight"><pre tabindex="0" class="chroma"><code class="language-shell" data-lang="shell"><span class="line"><span class="cl">❯ kubectl port-forward nuxt-k8s-6f797f85d5-dmcb6 3002:80
</span></span><span class="line"><span class="cl">Forwarding from 127.0.0.1:3002 -&gt; <span class="m">80</span>
</span></span><span class="line"><span class="cl">Forwarding from <span class="o">[</span>::1<span class="o">]</span>:3002 -&gt; <span class="m">80</span></span></span></code></pre></div>
</div>
<p>Now, you can access <a href="http://localhost:3002" target="_blank" rel="noopener noreferrer">localhost:3002</a>
 on local. You will see the application running on the cluster.</p>
<p><img src="/2021/03/how-to-deploy-nuxt-js-on-kubernetes-cluster-part-2/images/nuxt-k8s-screenshot-1.png" alt="image" title="Nuxt.js Application Screen"></p>
<h2 id="conclusion">Conclusion</h2>
<p>In this tutorial, we continued to the deployment of Nuxt.js application on a Kubernetes cluster. We created <code>Deployment</code> and <code>Service</code> manifests to be able to run our application on the cluster.</p>
<p>Finally, we could use the service to access Kubernetes Pod to reach our Nuxt.js application.</p>
]]></content:encoded></item><item><title>How to deploy Nuxt JS on Kubernetes Cluster (Part 1)</title><link>https://omegion.dev/2021/03/how-to-deploy-nuxt-js-on-kubernetes-cluster-part-1/</link><pubDate>Fri, 19 Mar 2021 00:00:00 +0000</pubDate><guid>https://omegion.dev/2021/03/how-to-deploy-nuxt-js-on-kubernetes-cluster-part-1/</guid><description>A tutorial about deploying Nuxt.js application on Kubernetes cluster in minutes.</description><content:encoded><![CDATA[<h2 id="introduction">Introduction</h2>
<p>Kubernetes is a fully managed container &hellip;</p>
<p>I am a big fan of Nuxt.js, recently created my personal blog where you read this post. I decided to host my personal blog Nuxt.js application on my self-managed Kubernetes cluster. On the way of deployment, I gained quite an amount of experience how to containerize a Nuxt.js application.</p>
<p>Since Nuxt.js supports full static application, our Nuxt.js application will be around <code>50MB</code>, which is great!</p>
<h2 id="prerequisites">Prerequisites</h2>
<p>Before you get started, you will need to have a number of things.</p>
<ol>
<li><code>yarn</code></li>
</ol>
<h2 id="install-nuxtjs">Install Nuxt.js</h2>
<div class="code-block">
  <div class="code-block-header">
    <span class="code-block-lang">shell</span>
    <button type="button" class="code-copy" aria-label="Copy code">
      <span class="code-copy-icon"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><rect x="9" y="9" width="13" height="13" rx="2" ry="2"/><path d="M5 15H4a2 2 0 01-2-2V4a2 2 0 012-2h9a2 2 0 012 2v1"/></svg>
</span>
      <span class="code-copy-icon code-copy-icon-check"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><polyline points="20 6 9 17 4 12"/></svg>
</span>
      <span class="code-copy-label">Copy</span>
    </button>
  </div>
  <div class="highlight"><pre tabindex="0" class="chroma"><code class="language-shell" data-lang="shell"><span class="line"><span class="cl">yarn create nuxt-app nuxt-k8s
</span></span><span class="line"><span class="cl">create-nuxt-app v3.6.0
</span></span><span class="line"><span class="cl">✨  Generating Nuxt.js project in nuxt-k8s
</span></span><span class="line"><span class="cl">? Project name: nuxt-k8s
</span></span><span class="line"><span class="cl">? Programming language: TypeScript
</span></span><span class="line"><span class="cl">? Package manager: Yarn
</span></span><span class="line"><span class="cl">? UI framework: Buefy
</span></span><span class="line"><span class="cl">? Nuxt.js modules:
</span></span><span class="line"><span class="cl">? Linting tools: <span class="o">(</span>Press &lt;space&gt; to <span class="k">select</span>, &lt;a&gt; to toggle all, &lt;i&gt; to invert selection<span class="o">)</span>
</span></span><span class="line"><span class="cl">? Testing framework: None
</span></span><span class="line"><span class="cl">? Rendering mode: Universal <span class="o">(</span>SSR / SSG<span class="o">)</span>
</span></span><span class="line"><span class="cl">? Deployment target: Static <span class="o">(</span>Static/Jamstack hosting<span class="o">)</span>
</span></span><span class="line"><span class="cl">? Development tools: <span class="o">(</span>Press &lt;space&gt; to <span class="k">select</span>, &lt;a&gt; to toggle all, &lt;i&gt; to invert selection<span class="o">)</span>
</span></span><span class="line"><span class="cl">? What is your GitHub username?
</span></span><span class="line"><span class="cl">? Version control system: Git</span></span></code></pre></div>
</div>
<h3 id="running-nuxt-application">Running Nuxt Application</h3>
<p>Now, let&rsquo;s run our application with:</p>
<div class="code-block">
  <div class="code-block-header">
    <span class="code-block-lang">shell</span>
    <button type="button" class="code-copy" aria-label="Copy code">
      <span class="code-copy-icon"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><rect x="9" y="9" width="13" height="13" rx="2" ry="2"/><path d="M5 15H4a2 2 0 01-2-2V4a2 2 0 012-2h9a2 2 0 012 2v1"/></svg>
</span>
      <span class="code-copy-icon code-copy-icon-check"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><polyline points="20 6 9 17 4 12"/></svg>
</span>
      <span class="code-copy-label">Copy</span>
    </button>
  </div>
  <div class="highlight"><pre tabindex="0" class="chroma"><code class="language-shell" data-lang="shell"><span class="line"><span class="cl"><span class="nb">cd</span> nuxt-k8s
</span></span><span class="line"><span class="cl">yarn run dev</span></span></code></pre></div>
</div>
<p>If you access <a href="http://localhost:3000" target="_blank" rel="noopener noreferrer">localhost:3000</a>
 you will see the application.</p>
<p><img src="/2021/03/how-to-deploy-nuxt-js-on-kubernetes-cluster-part-1/images/nuxt-k8s-screenshot-1.png" alt="image" title="Nuxt.js Application Screen"></p>
<h2 id="dockerization">Dockerization</h2>
<p>Every application that runs on Kubernetes is nothing but a container. So, we need to create <code>Dockerfile</code> for our Nuxt.js application. For this tutorial, we will deploy a fully static website. Eventually, Nuxt.js has already a built-in mechanism to generate our static files.</p>
<p>Our <code>Dockerfile</code> will have two main stages. If you want to learn more about
multi-stage Dockerfile, check <a href="https://omegion.dev/2022/03/how-to-create-multi-stage-dockerfile/">How to Create Multi-stage Dockerfile</a>
 post.</p>
<p>The first stage will install dependencies and generate static files, the second stage will serve these static files with <code>Nginx</code>.</p>
<p>Let&rsquo;s create <code>Dockerfile</code> in the application codebase.</p>
<div class="code-block">
  <div class="code-block-header">
    <span class="code-block-lang">shell</span>
    <button type="button" class="code-copy" aria-label="Copy code">
      <span class="code-copy-icon"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><rect x="9" y="9" width="13" height="13" rx="2" ry="2"/><path d="M5 15H4a2 2 0 01-2-2V4a2 2 0 012-2h9a2 2 0 012 2v1"/></svg>
</span>
      <span class="code-copy-icon code-copy-icon-check"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><polyline points="20 6 9 17 4 12"/></svg>
</span>
      <span class="code-copy-label">Copy</span>
    </button>
  </div>
  <div class="highlight"><pre tabindex="0" class="chroma"><code class="language-shell" data-lang="shell"><span class="line"><span class="cl">touch Dockerfile</span></span></code></pre></div>
</div>
<p>Paste the following content in the created file.</p>
<div class="code-block">
  <div class="code-block-header">
    <span class="code-block-lang">docker</span>
    <button type="button" class="code-copy" aria-label="Copy code">
      <span class="code-copy-icon"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><rect x="9" y="9" width="13" height="13" rx="2" ry="2"/><path d="M5 15H4a2 2 0 01-2-2V4a2 2 0 012-2h9a2 2 0 012 2v1"/></svg>
</span>
      <span class="code-copy-icon code-copy-icon-check"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><polyline points="20 6 9 17 4 12"/></svg>
</span>
      <span class="code-copy-label">Copy</span>
    </button>
  </div>
  <div class="highlight"><pre tabindex="0" class="chroma"><code class="language-docker" data-lang="docker"><span class="line"><span class="cl"><span class="c"># Build Stage</span><span class="err">
</span></span></span><span class="line"><span class="cl"><span class="k">FROM</span><span class="w"> </span><span class="s">node:lts-alpine</span><span class="w"> </span><span class="k">as</span><span class="w"> </span><span class="s">build-stage</span><span class="err">
</span></span></span><span class="line"><span class="cl"><span class="err">
</span></span></span><span class="line"><span class="cl"><span class="k">ENV</span> <span class="nv">NODE_ENV</span><span class="o">=</span>production
</span></span><span class="line"><span class="cl"><span class="k">ENV</span> <span class="nv">APP_HOME</span><span class="o">=</span>/usr/src/app<span class="err">
</span></span></span><span class="line"><span class="cl"><span class="err">
</span></span></span><span class="line"><span class="cl"><span class="k">RUN</span> mkdir -p <span class="nv">$APP_HOME</span><span class="err">
</span></span></span><span class="line"><span class="cl"><span class="err">
</span></span></span><span class="line"><span class="cl"><span class="k">COPY</span> . <span class="nv">$APP_HOME</span><span class="err">
</span></span></span><span class="line"><span class="cl"><span class="err">
</span></span></span><span class="line"><span class="cl"><span class="k">RUN</span> <span class="nb">cd</span> <span class="nv">$APP_HOME</span> <span class="o">&amp;&amp;</span> yarn install <span class="se">\
</span></span></span><span class="line"><span class="cl">  --prefer-offline <span class="se">\
</span></span></span><span class="line"><span class="cl">  --pure-lockfile <span class="se">\
</span></span></span><span class="line"><span class="cl">  --non-interactive <span class="se">\
</span></span></span><span class="line"><span class="cl">  --production<span class="o">=</span>true<span class="err">
</span></span></span><span class="line"><span class="cl"><span class="err">
</span></span></span><span class="line"><span class="cl"><span class="k">WORKDIR</span><span class="w"> </span><span class="s">$APP_HOME</span><span class="err">
</span></span></span><span class="line"><span class="cl"><span class="err">
</span></span></span><span class="line"><span class="cl"><span class="k">RUN</span> yarn run generate<span class="err">
</span></span></span><span class="line"><span class="cl"><span class="err">
</span></span></span><span class="line"><span class="cl"><span class="c"># Production Stage</span><span class="err">
</span></span></span><span class="line"><span class="cl"><span class="k">FROM</span><span class="w"> </span><span class="s">nginx:stable-alpine</span><span class="w"> </span><span class="k">as</span><span class="w"> </span><span class="s">production-stage</span><span class="err">
</span></span></span><span class="line"><span class="cl"><span class="err">
</span></span></span><span class="line"><span class="cl"><span class="k">ENV</span> <span class="nv">APP_HOME</span><span class="o">=</span>/usr/src/app<span class="err">
</span></span></span><span class="line"><span class="cl"><span class="err">
</span></span></span><span class="line"><span class="cl"><span class="k">COPY</span> --from<span class="o">=</span>build-stage <span class="nv">$APP_HOME</span>/dist /usr/share/nginx/html<span class="err">
</span></span></span><span class="line"><span class="cl"><span class="err">
</span></span></span><span class="line"><span class="cl"><span class="k">RUN</span> chown nginx:nginx /usr/share/nginx/html<span class="err">
</span></span></span><span class="line"><span class="cl"><span class="err">
</span></span></span><span class="line"><span class="cl"><span class="k">EXPOSE</span><span class="w"> </span><span class="s">80</span><span class="err">
</span></span></span><span class="line"><span class="cl"><span class="err">
</span></span></span><span class="line"><span class="cl"><span class="k">CMD</span> <span class="p">[</span><span class="s2">&#34;nginx&#34;</span><span class="p">,</span> <span class="s2">&#34;-g&#34;</span><span class="p">,</span> <span class="s2">&#34;daemon off;&#34;</span><span class="p">]</span></span></span></code></pre></div>
</div>
<h3 id="build-docker-image">Build Docker Image</h3>
<p>Once we created our <code>Dockerfile</code>, now we can good to create our docker image.</p>
<div class="code-block">
  <div class="code-block-header">
    <span class="code-block-lang">shell</span>
    <button type="button" class="code-copy" aria-label="Copy code">
      <span class="code-copy-icon"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><rect x="9" y="9" width="13" height="13" rx="2" ry="2"/><path d="M5 15H4a2 2 0 01-2-2V4a2 2 0 012-2h9a2 2 0 012 2v1"/></svg>
</span>
      <span class="code-copy-icon code-copy-icon-check"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><polyline points="20 6 9 17 4 12"/></svg>
</span>
      <span class="code-copy-label">Copy</span>
    </button>
  </div>
  <div class="highlight"><pre tabindex="0" class="chroma"><code class="language-shell" data-lang="shell"><span class="line"><span class="cl">docker build -t nuxt-k8s .</span></span></code></pre></div>
</div>
<p>If everything went well, check the image and its size:</p>
<div class="code-block">
  <div class="code-block-header">
    <span class="code-block-lang">shell</span>
    <button type="button" class="code-copy" aria-label="Copy code">
      <span class="code-copy-icon"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><rect x="9" y="9" width="13" height="13" rx="2" ry="2"/><path d="M5 15H4a2 2 0 01-2-2V4a2 2 0 012-2h9a2 2 0 012 2v1"/></svg>
</span>
      <span class="code-copy-icon code-copy-icon-check"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><polyline points="20 6 9 17 4 12"/></svg>
</span>
      <span class="code-copy-label">Copy</span>
    </button>
  </div>
  <div class="highlight"><pre tabindex="0" class="chroma"><code class="language-shell" data-lang="shell"><span class="line"><span class="cl">❯ docker image ls                 
</span></span><span class="line"><span class="cl">REPOSITORY   TAG       IMAGE ID       CREATED          SIZE
</span></span><span class="line"><span class="cl">nuxt-k8s     latest    c8c54ee0d07f   <span class="m">15</span> minutes ago   23.7MB</span></span></code></pre></div>
</div>
<p>As we generate only static files of our application, we only need to put generated files to the docker image.</p>
<h3 id="test-docker-image">Test Docker Image</h3>
<p>Now, let&rsquo;s test the docker image if it can render our Nuxt.js application.</p>
<div class="code-block">
  <div class="code-block-header">
    <span class="code-block-lang">shell</span>
    <button type="button" class="code-copy" aria-label="Copy code">
      <span class="code-copy-icon"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><rect x="9" y="9" width="13" height="13" rx="2" ry="2"/><path d="M5 15H4a2 2 0 01-2-2V4a2 2 0 012-2h9a2 2 0 012 2v1"/></svg>
</span>
      <span class="code-copy-icon code-copy-icon-check"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><polyline points="20 6 9 17 4 12"/></svg>
</span>
      <span class="code-copy-label">Copy</span>
    </button>
  </div>
  <div class="highlight"><pre tabindex="0" class="chroma"><code class="language-shell" data-lang="shell"><span class="line"><span class="cl">docker run -ti -p 3001:80 nuxt-k8s</span></span></code></pre></div>
</div>
<p>If you access <a href="http://localhost:3001" target="_blank" rel="noopener noreferrer">localhost:3001</a>
, you will access your built application.</p>
<h2 id="conclusion">Conclusion</h2>
<p>In this tutorial, we created Nuxt.js with Buefy CSS framework. Then, we could create a <code>Dockerfile</code> with multiple stages which enabled us to reduce an enormous amount of space of the docker image. Finally, we could build and test our docker image.</p>
<p>I am going to continue this tutorial series on the <nuxt-link to="/how-to-run-nuxt-on-kubernetes-2">next article</nuxt-link>. We will be deploying our Nuxt.js application on the Kubernetes cluster.</p>
<p>Continue this tutorial on <a href="https://omegion.dev/2021/03/how-to-deploy-nuxt-js-on-kubernetes-cluster-part-2/">How to deploy Nuxt JS on Kubernetes Cluster (Part 2)</a>
.</p>
]]></content:encoded></item><item><title>CounterAPI</title><link>https://omegion.dev/projects/counter-api/</link><pubDate>Mon, 01 Mar 2021 00:00:00 +0000</pubDate><guid>https://omegion.dev/projects/counter-api/</guid><description>REST API for tracking counts and metrics, no database required.</description><content:encoded>&lt;p>CounterAPI is a REST API for tracking counts and metrics without running
a database. Increment a counter with one call — through the JavaScript
or Go client library, or the REST API directly — and watch it update on
a real-time dashboard. Counters live in workspaces with public/private
visibility and team access control.&lt;/p>
&lt;p>The free tier includes 1,000 counts/day, 5 public counters, 5
workspaces, and 3 teams of up to 5 users each — no credit card required.&lt;/p>
</content:encoded></item><item><title>Database Backup with Kubernetes Cron Job</title><link>https://omegion.dev/2021/02/database-backup-with-kubernetes-cron-job/</link><pubDate>Tue, 16 Feb 2021 00:00:00 +0000</pubDate><guid>https://omegion.dev/2021/02/database-backup-with-kubernetes-cron-job/</guid><description>A tutorial on how to create a database backup with Kubernetes CronJob.</description><content:encoded><![CDATA[<h2 id="introduction">Introduction</h2>
<p>If you have a self-managed database for your projects, it is hard to manage the
backups. You can use <code>db-backup</code> tool to create a backup on the S3 bucket easily
within your cluster.</p>
<h2 id="prerequisites">Prerequisites</h2>
<p>Before you get started, you will need to have a number of things.</p>
<ol>
<li>A running Kubernetes cluster.</li>
<li><code>kubectl</code> installed. If not, you will need it to manage your clusters.</li>
<li>Have a <code>KUBECONFIG</code> file (default location is ~/.kube/config).</li>
</ol>
<h2 id="what-is-cron-job">What is Cron Job</h2>
<p>Kubernetes CronJob is a job that repeats itself with a given schedule. CronJob is a
perfect fit for creating a database backup for your self-managed database.</p>
<h2 id="create-cronjob">Create CronJob</h2>
<p>Let&rsquo;s create <code>backup-cronjob.yaml</code> with the given content.</p>
<div class="code-block">
  <div class="code-block-header">
    <span class="code-block-lang">yaml</span>
    <button type="button" class="code-copy" aria-label="Copy code">
      <span class="code-copy-icon"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><rect x="9" y="9" width="13" height="13" rx="2" ry="2"/><path d="M5 15H4a2 2 0 01-2-2V4a2 2 0 012-2h9a2 2 0 012 2v1"/></svg>
</span>
      <span class="code-copy-icon code-copy-icon-check"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><polyline points="20 6 9 17 4 12"/></svg>
</span>
      <span class="code-copy-label">Copy</span>
    </button>
  </div>
  <div class="highlight"><pre tabindex="0" class="chroma"><code class="language-yaml" data-lang="yaml"><span class="line"><span class="cl"><span class="nt">apiVersion</span><span class="p">:</span><span class="w"> </span><span class="l">batch/v1</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="nt">kind</span><span class="p">:</span><span class="w"> </span><span class="l">CronJob</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="nt">metadata</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">  </span><span class="nt">name</span><span class="p">:</span><span class="w"> </span><span class="l">database-backup-job</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">  </span><span class="nt">namespace</span><span class="p">:</span><span class="w"> </span><span class="l">default</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="nt">spec</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">  </span><span class="nt">schedule</span><span class="p">:</span><span class="w"> </span><span class="s2">&#34;0 0 * * *&#34;</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">  </span><span class="nt">jobTemplate</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">spec</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span><span class="nt">template</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">        </span><span class="nt">spec</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">          </span><span class="nt">restartPolicy</span><span class="p">:</span><span class="w"> </span><span class="l">OnFailure</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">          </span><span class="nt">containers</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">            </span>- <span class="nt">name</span><span class="p">:</span><span class="w"> </span><span class="l">db-backup</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">              </span><span class="nt">image</span><span class="p">:</span><span class="w"> </span><span class="s2">&#34;ghcr.io/omegion/db-backup:v0.9.0&#34;</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">              </span><span class="nt">imagePullPolicy</span><span class="p">:</span><span class="w"> </span><span class="l">IfNotPresent</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">              </span><span class="nt">args</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">                </span>- <span class="l">s3</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">                </span>- <span class="l">export</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">                </span>- --<span class="l">host=$DB_HOST</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">                </span>- --<span class="l">port=$DB_PORT</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">                </span>- --<span class="l">databases=$DATABASE_NAMES</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">                </span>- --<span class="l">username=$DB_USER</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">                </span>- --<span class="l">password=$DB_PASS</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">                </span>- --<span class="l">bucket=$BUCKET_NAME</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">              </span><span class="nt">env</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">                </span>- <span class="nt">name</span><span class="p">:</span><span class="w"> </span><span class="l">DB_HOST</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">                  </span><span class="nt">value</span><span class="p">:</span><span class="w"> </span><span class="s2">&#34;&#34;</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">                </span>- <span class="nt">name</span><span class="p">:</span><span class="w"> </span><span class="l">DB_PORT</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">                  </span><span class="nt">value</span><span class="p">:</span><span class="w"> </span><span class="s2">&#34;&#34;</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">                </span>- <span class="nt">name</span><span class="p">:</span><span class="w"> </span><span class="l">DB_USER</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">                  </span><span class="nt">value</span><span class="p">:</span><span class="w"> </span><span class="s2">&#34;&#34;</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">                </span>- <span class="nt">name</span><span class="p">:</span><span class="w"> </span><span class="l">DB_PASS</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">                  </span><span class="nt">value</span><span class="p">:</span><span class="w"> </span><span class="s2">&#34;&#34;</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">                </span>- <span class="nt">name</span><span class="p">:</span><span class="w"> </span><span class="l">DATABASE_NAMES</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">                  </span><span class="nt">value</span><span class="p">:</span><span class="w"> </span><span class="s2">&#34;&#34;</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">                </span>- <span class="nt">name</span><span class="p">:</span><span class="w"> </span><span class="l">BUCKET_NAME</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">                  </span><span class="nt">value</span><span class="p">:</span><span class="w"> </span><span class="s2">&#34;&#34;</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">                </span>- <span class="nt">name</span><span class="p">:</span><span class="w"> </span><span class="l">AWS_ACCESS_KEY_ID</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">                  </span><span class="nt">value</span><span class="p">:</span><span class="w"> </span><span class="s2">&#34;&#34;</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">                </span>- <span class="nt">name</span><span class="p">:</span><span class="w"> </span><span class="l">AWS_SECRET_ACCESS_KEY</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">                  </span><span class="nt">value</span><span class="p">:</span><span class="w"> </span><span class="s2">&#34;&#34;</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">                </span>- <span class="nt">name</span><span class="p">:</span><span class="w"> </span><span class="l">AWS_REGION</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">                  </span><span class="nt">value</span><span class="p">:</span><span class="w"> </span><span class="s2">&#34;&#34;</span></span></span></code></pre></div>
</div>
<p>Now, we can create CronJob with <code>kubectl</code>.</p>
<div class="code-block">
  <div class="code-block-header">
    <span class="code-block-lang">shell</span>
    <button type="button" class="code-copy" aria-label="Copy code">
      <span class="code-copy-icon"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><rect x="9" y="9" width="13" height="13" rx="2" ry="2"/><path d="M5 15H4a2 2 0 01-2-2V4a2 2 0 012-2h9a2 2 0 012 2v1"/></svg>
</span>
      <span class="code-copy-icon code-copy-icon-check"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><polyline points="20 6 9 17 4 12"/></svg>
</span>
      <span class="code-copy-label">Copy</span>
    </button>
  </div>
  <div class="highlight"><pre tabindex="0" class="chroma"><code class="language-shell" data-lang="shell"><span class="line"><span class="cl">kubectl apply -f backup-cronjob.yaml</span></span></code></pre></div>
</div>
<h3 id="verify-cronjob">Verify CronJob</h3>
<p>Let&rsquo;s verify that the CronJob is created successfully.</p>
<div class="code-block">
  <div class="code-block-header">
    <span class="code-block-lang">shell</span>
    <button type="button" class="code-copy" aria-label="Copy code">
      <span class="code-copy-icon"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><rect x="9" y="9" width="13" height="13" rx="2" ry="2"/><path d="M5 15H4a2 2 0 01-2-2V4a2 2 0 012-2h9a2 2 0 012 2v1"/></svg>
</span>
      <span class="code-copy-icon code-copy-icon-check"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><polyline points="20 6 9 17 4 12"/></svg>
</span>
      <span class="code-copy-label">Copy</span>
    </button>
  </div>
  <div class="highlight"><pre tabindex="0" class="chroma"><code class="language-shell" data-lang="shell"><span class="line"><span class="cl">kubectl get cronjob database-backup-job                                             
</span></span><span class="line"><span class="cl">NAME                  SCHEDULE    SUSPEND   ACTIVE   LAST SCHEDULE   AGE
</span></span><span class="line"><span class="cl">database-backup-job   <span class="m">0</span> <span class="m">0</span> * * *   False     <span class="m">0</span>        &lt;none&gt;          27s</span></span></code></pre></div>
</div>
<h3 id="test-cronjob">Test CronJob</h3>
<p>The CronJob that we created will be executed every day. So, let&rsquo;s create a
temporary job from the CronJob that we can test our Job.</p>
<div class="code-block">
  <div class="code-block-header">
    <span class="code-block-lang">shell</span>
    <button type="button" class="code-copy" aria-label="Copy code">
      <span class="code-copy-icon"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><rect x="9" y="9" width="13" height="13" rx="2" ry="2"/><path d="M5 15H4a2 2 0 01-2-2V4a2 2 0 012-2h9a2 2 0 012 2v1"/></svg>
</span>
      <span class="code-copy-icon code-copy-icon-check"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><polyline points="20 6 9 17 4 12"/></svg>
</span>
      <span class="code-copy-label">Copy</span>
    </button>
  </div>
  <div class="highlight"><pre tabindex="0" class="chroma"><code class="language-shell" data-lang="shell"><span class="line"><span class="cl">kubectl create job --from<span class="o">=</span>cronjob/database-backup-job database-backup-job-manual-001</span></span></code></pre></div>
</div>
<h2 id="conclusion">Conclusion</h2>
<p>In this tutorial, we created a CronJob to create database backups for our
self-managed database.</p>
]]></content:encoded></item><item><title>SSH Key Manager</title><link>https://omegion.dev/projects/ssh-manager/</link><pubDate>Mon, 01 Feb 2021 00:00:00 +0000</pubDate><guid>https://omegion.dev/projects/ssh-manager/</guid><description>SSH Key Manager for 1Password, Bitwarden and AWS S3.</description><content:encoded></content:encoded></item><item><title>Dev Tasks Manager</title><link>https://omegion.dev/projects/dev-tasks/</link><pubDate>Tue, 01 Sep 2020 00:00:00 +0000</pubDate><guid>https://omegion.dev/projects/dev-tasks/</guid><description>Task management tool tailored for developers.</description><content:encoded></content:encoded></item><item><title>First 10 Days Report of Crawly</title><link>https://omegion.dev/2020/05/first-10-days-report-of-crawly/</link><pubDate>Sat, 16 May 2020 00:00:00 +0000</pubDate><guid>https://omegion.dev/2020/05/first-10-days-report-of-crawly/</guid><description>This is the first 10 days report of my 2nd SaaS Side Project Crawly.</description><content:encoded><![CDATA[<h3 id="introduction">Introduction</h3>
<p>During my busy daily routine between home, university and work. I wanted to create a side project to get some
experience and also solve a problem of the developers’ world. Then, an idea pops up in my mind that I was also
struggling while I was developing my first side project.</p>
<p>The problem was detecting broken links. There are many useful tools that you can use on different platforms and
languages but in every case you would need to integrate and do some codding to make it work out. I wanted to solve
this problem with Crawly without integrating or codding. Crawly has a very basic idea. It crawls a website to find
out broken internal and external links. It only needs the website address of a web application.</p>
<a class="link-row-ext project-card" href="/projects/crawly/">
  <div class="link-row-ext-inner">
    <span class="project-card-title" style="view-transition-name: vt-title-d72e9faef7afb9737a588124c2c46469">Crawly Uptime Monitoring <span class="badge">Discontinued</span></span>
    <div class="project-card-meta">Launched November 2018</div>
    <div class="link-row-desc">Modern uptime monitoring tool with extra features.</div>
  </div>
  <span class="ext-arrow">&#8599;</span>
</a>
<h3 id="what-is-crawly">What is Crawly?</h3>
<p>Crawly is a tool that crawls your web applications to find out broken pages to help you to maintain it. Crawly has a
powerful alerting system that if any page of your project is broken, you will get a notification. Crawly also offers
you to monitor your all pages of your application in historically. Since I launched the Crawly. I have added more
features and many of them on the way.</p>
<h3 id="launching">Launching</h3>
<p>I like to read developers’ stories on IndieHackers. I remember that one of the developers was sharing his/her idea
about the launching of the side project. He/she said that do not wait until your project will be perfect, just launch
it as soon as you can. Literally, I did the same. I launched Crawly on ProductHunt without making it “perfect”, even
I didn’t have time to correct the typos. Launching at ProductHunt was kinda successful.</p>
<p><img src="/2020/05/first-10-days-report-of-crawly/images/screen.png" alt="image" title="Producthunt Screen"></p>
<p>Crawly was the 2nd product of the day, It got approximately <code>500</code> up votes.</p>
]]></content:encoded></item><item><title>Hocam Critic Platform</title><link>https://omegion.dev/projects/hocam-app/</link><pubDate>Fri, 01 Nov 2019 00:00:00 +0000</pubDate><guid>https://omegion.dev/projects/hocam-app/</guid><description>University lecturer critic platform for students.</description><content:encoded></content:encoded></item><item><title>Crawly Uptime Monitoring</title><link>https://omegion.dev/projects/crawly/</link><pubDate>Thu, 01 Nov 2018 00:00:00 +0000</pubDate><guid>https://omegion.dev/projects/crawly/</guid><description>Modern uptime monitoring tool with extra features.</description><content:encoded></content:encoded></item><item><title>Vue JS Placeholder</title><link>https://omegion.dev/projects/vue-js-placeholder/</link><pubDate>Mon, 01 Jan 2018 00:00:00 +0000</pubDate><guid>https://omegion.dev/projects/vue-js-placeholder/</guid><description>Vue JS plugin for placeholder components.</description><content:encoded></content:encoded></item></channel></rss>