2026-09-11 08:00:00
After having used practically the same xmonad configuration for a decade and a half I’ve now modernized my setup with the scrollable-tiling Wayland compositor Niri. It’s been a bit of a struggle to unlearn my old workflow but I’m really growing to love Niri’s scrollable workflow, especially on my new super ultrawide display.

What kicked off my Niri journey was the purchase of a new super ultrawide monitor.
I bought the 57” Odyssey Neo G9 as it was the largest monitor I could find. (It’s marketed as a “gaming” display but it’s really an amazing productivity display.)
It replaced my old 3-monitor setup:

I was debating whether to replace the center 31.5” monitor or replace all monitors with a single one but I think I made the right choice with the ultrawide. The curvature wasn’t an issue (I’ve come to prefer it) and the extra vertical space the portrait side monitors provided wasn’t as crucial as I thought.
I think an ultrawide is worth it just to get rid of the annoying bezels. Small things can be a big thing sometimes.
xmonad and Niri are similar yet different. Both automatically lay out windows as you spawn them but xmonad (at least the way I used it) follows a layout algorithm that re-flows using a “master” window and combines the rest of the windows into one space, while Niri lays out windows in columns.
The change is subtle but it implies that a new window won’t change the size of other windows. This is very nice if you spawn a lot of short-lived terminals or web browsers like I do and it reduces the amount of manual reshuffling I spend time on.
My xmonad workflow was more static than my Niri one.
In xmonad I made heavy use of workspaces, mapping ten workspaces mentally to different programs, such as 0 Firefox and 1 terminal logs on the left monitor; 3, 4 and 5 for different Neovim instances on the center monitor; 8 as chat and 9 for music or video on the right monitor.
I had no rules to enforce this; it’s an emergent behaviour that served me well for years.
With Niri it’s more dynamic. I still use workspaces but they no longer have direct shortcuts, I simply go up/down in the workspace list. Maybe I’ll add them in the future but with 3–4 workspaces that’s not as necessary. I spawn workspaces/windows when I need them and remove them when I’m done.
Usually it’s one workspace per project (yes, I’m now one of those who have multiple up at once) with all the related things such as editor, terminals, and browser with docs. I don’t typically utilize the full screen width and I try to keep the things I’m working on in the center, often leaving 10–30% gaps on the sides. Even though I don’t normally use the “endless scrolling” feature of Niri I re-center selected windows all the time so I can look straight ahead as much as possible.
As a fan of keyboard layouts of course I have to spend some time tinkering with good keyboard shortcuts (especially as Niri’s recommended keybinds don’t map well with my custom keyboard or custom layout).
What I did was add a new navigation layer that’s enabled by holding Tab (ring + middle + index on the left-hand side) with all Niri related movement and layout keybinds.
In the graphics above, all green-colored keys emit Gui (which gates all window manager commands) and you can see:
Long press on Close Window to close a window.
The long press requirement prevents accidentally closing windows.
Arrows move through columns/windows.
Long press resizes them.
Workspace Up/Down focuses a different workspace.
Center a column.
Consume/Expel to combine windows into one column.
(consume-or-expel-window-left/consume-or-expel-window-right)
Expand Column makes a column take up all remaining space.
(expand-column-to-available-width)
Audio controls.
To press them I release the index finger (keeping the ring and middle finger pressed to keep the layer active) and use the index to press the audio buttons.
Mouse buttons.
In Niri you can move floating windows with Gui + Left Mouse and Gui + Right Mouse to resize them.
As my main mouse is a trackball integrated into the keyboard
I had to add them to the left-hand side.
I ended up using QMK’s customizable key repress feature that allows me to:
Tab combo with my three fingers (layer is active)
Gui + Left Mouse key down)
And similarly for the right mouse button to resize with the middle finger. Works great!
Because there are so many commands I want to send I placed Ctrl on the thumb that provides movement-related commands like so:
For example:
Arrows move columns/windows in the four directions.
Move columns to the neighboring workspaces.
Center visible columns.
(center-visible-columns)
Slightly different consume/expel semantics.
(consume-window-into-column/expel-window-from-column)
These are triggered in the “normal” way by first pressing the Super combo and then another key on the base layer (I use autoshift so I shift with a long press).
Super + F toggle windowed fullscreen (keep column width)
Super + Shift + F fullscreen window (over the entire display)
Super + M maximize column (moves other columns)
Super + Enter terminal
Super + E Noctalia’s launcher (also exists on the navigation layer as Launch)
Super + S show Noctalia control center
Super + Shift + S show Noctalia settings
Super + Q power off monitors (they wake on input)
Super + Shift + Q show Noctalia session menu (reboot etc)
Super + Shift + L lock screen
Super + H show hotkey overlay
Super + P interactive screenshot
Super + Shift + P screenshot selected window
In the process of moving from xmonad to Niri I also moved from Void Linux to CachyOS and I let the installer install Niri and give me a basic configuration together with Noctalia (that provides a statusbar, notifications, and a bunch of things you apparently need).

Feels absolutely required on this screen otherwise things end up in the corners.
Force Firefox onto XWayland as the Wayland popup manager is broken:
environment {
MOZ_ENABLE_WAYLAND "0"
}
For some reason dead keys were broken in Ghostty.
This is bad for me as the OS keyboard is set to Swedish and it uses them to type ~ (quite a crucial character for a programmer).
The fix:
environment {
GTK_IM_MODULE "ibus"
QT_IM_MODULE "ibus"
XMODIFIERS "@im=ibus"
}
This needs ibus installed and running.
Noctalia discovers custom color schemes under ~/.config/noctalia/colorschemes/<Name>/<Name>.json, so I dropped in my trusty Melange colorscheme there:
},
},
}
}
}
Then pick the colorscheme:
"colorSchemes":
}
The default appearance was pretty I admit but way too much blank space and weirdness. Some tweaks:
layout {
// Required for noctalia-shell to set wallpaper
background-color "transparent"
// Never auto-center focused columns (too much movement)
center-focused-column "never"
// But do center a single window
always-center-single-column
// No extra space around it all
struts {}
// No gaps between windows
gaps 0
// The focus ring was annoying
focus-ring {
off
}
// Use a border with consistent width for all windows instead
border {
on
width 2
active-color "#ebc06d"
inactive-color "#403a36"
}
// Setting widths is important with such a large screen
preset-column-widths {
proportion 0.15
proportion 0.3
proportion 0.4
}
default-column-width { proportion 0.15; }
// Heights too, why not?
preset-window-heights {
proportion 0.15
proportion 0.5
proportion 1.0
}
}
// Prevent the mouse from opening the overview in the corners
gestures {
hot-corners {
off
}
}
Niri has the always-center-single-column option, which is nice as I want to keep as much as possible in the center of the monitor when I’m working.
But I very frequently use 2–3 smaller windows and with my frequent opening and closing I’d like them centered too.
Luckily, Niri has an IPC you can use to make a small program that reacts to events and does this for you. I made a small rust project using the niri-ipc crate that does this for me:
[dependencies]
niri-ipc = "26.4.0"
use HashMap;
use io;
use Socket;
use ;
;
;
;
workspace: ,
width: f64,
let mut socket = connect?;
if !matches!
eprintln!;
exit;
let mut known: = new;
let mut read_event = socket.read_events;
loop
let result = match read_event?
// A full snapshot of the current state. Just refresh our state.
WindowsChanged =>
known = windows
.into_iter
.map
WindowId,
WindowState
workspace: w.workspace_id.map,
width: w.layout.tile_size.0,
,
.collect;
Ok
WindowOpenedOrChanged =>
let workspace = window.workspace_id.map;
let entry = WindowState
workspace,
width: window.layout.tile_size.0,
;
let prev = known.insert;
match prev
// Don't center floats.
_ if window.is_floating => Ok,
// New window, try to re-center.
None => maybe_center_new,
// Window changed workspace, try to re-center.
Some if state.workspace != workspace => center_focused_if_fits,
// Skip other things.
Some => Ok,
WindowClosed =>
if known.remove.is_some
center_focused_if_fits
else
Ok
WindowLayoutsChanged =>
// Only re-center if the width was changed, otherwise our re-center will
// loop back indefinitely.
let mut resized = false;
for in changes
if let Some = known.get_mut
if .abs > 0.5
state.width = layout.tile_size.0;
resized = true;
if resized
center_focused_if_fits
else
Ok
_ => Ok,
;
if let Err = result
eprintln!;
/// Center a newly created window if the workspace is focused and if there's surrounding free space left.
let Some = window.workspace_id.map else
return Ok;
;
let Some = focused_workspace? else
return Ok;
;
if WorkspaceId == workspace_id
center_if_fits?;
Ok
/// Center windows in the focused workspace if there's surrounding free space left.
if let Some = focused_workspace?
center_if_fits?;
Ok
/// Center windows in the workspace if there's surrounding free space left.
let Some = workspace.output.as_deref.map else
return Ok;
;
let Some = output_width? else
return Ok;
;
if workspace_width? < f64from
center_visible_columns?;
Ok
/// Issue a one-shot query to Niri, wait, and return the response.
match connect?.send?
Ok => Ok,
Err => Err,
/// Get the focused workspace.
match query?
Workspaces => Ok,
_ => Ok,
/// Get the width of an output (monitor).
match query?
Outputs => Ok
.get
.and_then
.map,
_ => Ok,
/// Calculates the width of all columns in the workspace.
let Windows = query? else
return Ok;
;
let mut columns: = new;
for w in windows
if w.workspace_id.map != Some
continue;
if let Some = w.layout.pos_in_scrolling_layout
let width = columns.entry.or_insert;
*width = width.max;
Ok
/// Send a command to center the visible columns.
if let Err = connect?.send?
eprintln!;
Ok
One catch is that if a new window overflows the monitor width, the script won’t center the columns even if there would be free space left afterwards.
This is a little weird but it’s consistent with Niri’s center-visible-columns command.
I had a small itch to try to hack around it but in the end I left it alone…
Yes, absolutely. Niri has been a huge upgrade for me in combination with a single wide screen. My xmonad setup worked really well with three monitors—arguably a better fit in that context than Niri—but for the big-screen use-case Niri is superior.
I’m curious how it holds up on my laptop, once I gather enough energy to install CachyOS on it… But that’s a side quest. The big-screen setup I spend most of my days in is the best I’ve ever had, and I have no desire to go back.
2026-08-06 08:00:00
Accessing services via raw IP addresses isn’t that swell; I’m no Rain Man.
It’s time to set up subdomains for my hietala.xyz domain for internal use.
We’ll use the Gateway API to set up routes, cert-manager to give us https without self-signed browser warnings, and ExternalDNS to set up DNS overrides.
I believe the flow of resolving http://argocd.hietala.xyz to a service looks something like this:
digraph
rankdir=LR
"Browser" -> "OPNsense DNS"
"OPNsense DNS" -> "Browser"
"Browser" -> "Cilium Gateway"
"Cilium Gateway" -> "HTTPRoute"
"HTTPRoute" -> "Service"
"Service" -> "Pod"
}
First the browser asks my router running OPNsense about argocd.hietala.xyz and gets the Gateway IP address.
The request then flows through a route (http or https), to a Service, and eventually a Pod where ArgoCD is running.
We’ll handle these one at a time but let’s start by creating the Gateway and giving it an IP (we’ll use 10.1.4.101):
apiVersion: gateway.networking.k8s.io/v1
kind: Gateway
metadata:
name: main
namespace: kube-system
annotations:
io.cilium/lb-ipam-ips: "10.1.4.101"
spec:
gatewayClassName: cilium
Then we need to tell the Gateway to manage all http routes for all namespaces (we’ll get back to https):
# Continued from the above Gateway manifest
spec:
gatewayClassName: cilium
listeners:
- name: http
port: 80
protocol: HTTP
allowedRoutes:
namespaces:
from: All
Then we can add an HTTPRoute for our ArgoCD application that targets argocd-server at port 80:
# Below the ArgoCD Application setup
# Resources in the same file are separated by `---`
---
apiVersion: gateway.networking.k8s.io/v1
kind: HTTPRoute
metadata:
name: argocd
namespace: argocd
spec:
parentRefs:
- name: main
namespace: kube-system
sectionName: http
hostnames:
- argocd.hietala.xyz
rules:
- backendRefs:
- name: argocd-server
port: 80
If we then add a DNS override from http://argocd.hietala.xyz to 10.1.4.101 (/etc/hosts or OPNsense or similar) then we should be able to reach http://argocd.hietala.xyz.
I want https://argocd.hietala.xyz to “just work” and for that we need to tell Gateway to manage https routes:
# ...
spec:
gatewayClassName: cilium
listeners:
- name: https
port: 443
protocol: HTTPS
tls:
mode: Terminate
certificateRefs:
- name: hietala-xyz-tls
allowedRoutes:
namespaces:
from: All
- name: http
# Http definition from before
(Note the tls addition that terminates using a not-yet-defined certificate.)
And add the https route itself:
apiVersion: gateway.networking.k8s.io/v1
kind: HTTPRoute
metadata:
name: argocd
namespace: argocd
spec:
parentRefs:
- name: main
namespace: kube-system
sectionName: https
hostnames:
- argocd.hietala.xyz
rules:
- backendRefs:
- name: argocd-server
port: 80
This doesn’t work just yet as we need to create the hietala-xyz-tls cert.
cert-manager seems like the standard way to manage certificates for Kubernetes. I don’t want to expose my services to the internet which means I need a DNS01 challenge. cert-manager doesn’t natively support Namecheap (bummer) but there’s an open source webhook Namecheap provider out there. It hasn’t been updated in a couple of years but I couldn’t find an alternative…
YOLO I guess?
The cert-manager manifest:
apiVersion: argoproj.io/v1alpha1
kind: Application
metadata:
name: cert-manager
namespace: argocd
annotations:
argocd.argoproj.io/sync-wave: "-1"
finalizers:
- resources-finalizer.argocd.argoproj.io
spec:
project: default
source:
repoURL: https://charts.jetstack.io
chart: cert-manager
targetRevision: v1.21.0
helm:
values: |
installCRDs: true
destination:
server: https://kubernetes.default.svc
namespace: cert-manager
syncPolicy:
syncOptions:
- CreateNamespace=true
automated:
prune: true
selfHeal: true
Note the installCRDs: true that makes things easier for us, and CreateNamespace=true which will create the cert-manager namespace for us too.
Saves some typing.
We also set sync-wave to -1 as it needs to sync before the cluster issuer and certificate that we’ll define later.
Then the namecheap webhook:
apiVersion: argoproj.io/v1alpha1
kind: Application
metadata:
name: cert-manager-webhook-namecheap
namespace: argocd
annotations:
argocd.argoproj.io/sync-wave: "-1"
finalizers:
- resources-finalizer.argocd.argoproj.io
spec:
project: default
source:
repoURL: https://github.com/kelvie/cert-manager-webhook-namecheap.git
targetRevision: HEAD
path: deploy/cert-manager-webhook-namecheap
helm:
# Identifier that our issuer will use
values: |
groupName: acme.namecheap.com
destination:
server: https://kubernetes.default.svc
namespace: cert-manager
syncPolicy:
syncOptions:
- CreateNamespace=false
automated:
prune: true
selfHeal: true
Then a ClusterIssuer that uses the namecheap webhook:
apiVersion: cert-manager.io/v1
kind: ClusterIssuer
metadata:
name: letsencrypt-staging
annotations:
# Must come after the cert-manager applications.
# It defaults to 0 anyway but this is more explicit.
argocd.argoproj.io/sync-wave: "0"
# If resources doesn't exist ArgoCD may complain.
argocd.argoproj.io/sync-options: SkipDryRunOnMissingResource=true
spec:
acme:
# Use the staging endpoint during testing!
server: https://acme-staging-v02.api.letsencrypt.org/directory
privateKeySecretRef:
name: letsencrypt-staging
solvers:
- dns01:
# Use the namecheap webhook
webhook:
# Same identifier the webhook defined above.
groupName: acme.namecheap.com
solverName: namecheap
config:
# These are very sensitive!
# Store them in a Sealed Secret
apiKeySecretRef:
name: namecheap-credentials
key: apiKey
apiUserSecretRef:
name: namecheap-credentials
key: apiUser
Make sure to use the staging issuer during testing to avoid rate limits.
When you’re done playing around you can switch to the production server at https://acme-v02.api.letsencrypt.org/directory.
Create the sealed secret:
kubectl create secret generic namecheap-credentials \
--namespace cert-manager \
--from-literal=apiKey="key" \
--from-literal=apiUser="user" \
--dry-run=client -o yaml \
| kubeseal --cert infrastructure/sealed-secrets-cert.pem \
--format yaml \
> gitops/apps/cert-manager/namecheap-secret.yaml
Finally we need to create the hietala-xyz-tls certificate that uses the issuer:
apiVersion: cert-manager.io/v1
kind: Certificate
metadata:
name: hietala-xyz
# Must match the namespace of the Gateway
namespace: kube-system
annotations:
# References the issuer so must be synced after.
argocd.argoproj.io/sync-wave: "2"
argocd.argoproj.io/sync-options: SkipDryRunOnMissingResource=true
spec:
# Gateway references this certificate using this name.
secretName: hietala-xyz-tls
dnsNames:
- "*.hietala.xyz"
- "hietala.xyz"
issuerRef:
# Switch to `letsencrypt-prod` later.
name: letsencrypt-staging
kind: ClusterIssuer
When all this has been synced we should be able to see that the certificate is created:
$ kubectl get certificate -n kube-system
NAME READY SECRET AGE
hietala-xyz True hietala-xyz-tls 4d20h
And that we can visit https://argocd.hietala.xyz (browser will warn while we use letsencrypt-staging, on prod it should be without errors).
(If not, then you have a bunch of debugging to do. Have fun!)
By now most of the things are set up in proper GitOps fashion but there’s still one thing I have to do manually: I have to add a DNS override to unbound (it’s on my OPNsense router). Doing it once is fine but it gets old fast.

A wildcard domain could work but I have other services running outside of Kubernetes, so I’d like a cleaner solution.
That solution is ExternalDNS, which automatically adds overrides for any existing Gateway HTTPRoute.
There’s a webhook provider for OPNsense that we’ll use.
The manifest:
apiVersion: argoproj.io/v1alpha1
kind: Application
metadata:
name: external-dns
namespace: argocd
finalizers:
- resources-finalizer.argocd.argoproj.io
spec:
project: default
sources:
- repoURL: https://kubernetes-sigs.github.io/external-dns/
chart: external-dns
targetRevision: 1.21.1
helm:
valueFiles:
- $values/gitops/apps/external-dns/values.yaml
- repoURL: https://git.hietala.xyz/tree/home-ops.git
targetRevision: HEAD
ref: values
destination:
server: https://kubernetes.default.svc
namespace: external-dns
syncPolicy:
syncOptions:
- CreateNamespace=true
automated:
prune: true
selfHeal: true
It loads values.yaml from the repo:
provider:
name: webhook
webhook:
image:
repository: ghcr.io/crutonjohn/external-dns-opnsense-webhook
tag: v1.0.0
env:
- name: OPNSENSE_HOST
value: "https://router.hietala.xyz"
# Remember to create the `external-dns` sealed secret.
- name: OPNSENSE_API_KEY
valueFrom:
secretKeyRef:
name: external-dns
key: api-key
- name: OPNSENSE_API_SECRET
valueFrom:
secretKeyRef:
name: external-dns
key: api-secret
sources:
- gateway-httproute
policy: sync
domainFilters:
- hietala.xyz
txtOwnerId: talos-dorne
This connects to OPNsense, sources routes from the Gateway, targets the hietala.xyz domain, and uses a new external-dns secret:
kubectl create secret generic external-dns \
--namespace external-dns \
--from-literal=api-key="key" \
--from-literal=api-secret="secret" \
--dry-run=client -o yaml \
| kubeseal --cert infrastructure/sealed-secrets-cert.pem \
--format yaml \
> gitops/apps/external-dns/external-dns-secret.yaml
With this I don’t have to add manual overrides anymore. Nothing like spending hours to automate a few minutes of work!
There’s a few moving parts but once done supporting new apps is satisfyingly easy.
For example, to expose the homeassistant service under https://ha.hietala.xyz this is enough:
apiVersion: gateway.networking.k8s.io/v1
kind: HTTPRoute
metadata:
name: homeassistant
spec:
parentRefs:
- name: main
namespace: kube-system
sectionName: https
hostnames:
- ha.hietala.xyz
rules:
- backendRefs:
- name: homeassistant
port: 8123
Commit and push, and https://ha.hietala.xyz is ready in a jiffy, certificates and DNS overrides included.
2026-07-23 08:00:00

Now we’re getting to the fun stuff: GitOps.
The act of pushing beautifully crafted .yaml files and seeing your Kubernetes cluster get red and stall out is surely what life’s all about.
We have our Kubernetes cluster and now we’re going to bootstrap ArgoCD so it starts syncing from our git repository. Using helm:
helm install argocd \
--repo https://argoproj.github.io/argo-helm \
argo-cd \
--namespace argocd \
--create-namespace \
--version 10.1.4
ArgoCD will by default generate an admin password and you can get it with the argocd tool:
# You can use the password to login to the web.
argocd admin initial-password -n argocd
# Make ArgoCD available at localhost:8123
kubectl port-forward -n argocd svc/argocd-server 8123:80
You can use the argocd CLI to manage ArgoCD but I’m going to prefer the declarative way.
You can use the port forward method above to access ArgoCD but having a fixed IP is nicer, especially if you want to use the argocd CLI (I only used it when trying to get it all working).
I use a separate service for the IP, which allows me to assign an IP outside of the Cilium load balance pool we setup previously (10.1.4.101–10.1.4.255).
It looks like this:
apiVersion: v1
kind: Service
metadata:
name: argocd-server-ip
namespace: argocd
spec:
type: ClusterIP
externalIPs:
- 10.1.4.51
selector:
app.kubernetes.io/name: argocd-server
ports:
- name: http
port: 80
targetPort: 8080
It simply routes 10.1.4.51:80 to the IP of argocd-server and port 8080.
We need to apply it:
kubectl apply -f gitops/bootstrap/argocd.yaml
And you should see it assigned and able to visit it on the web:
$ kubectl get svc -n argocd argocd-server-ip
NAME TYPE CLUSTER-IP EXTERNAL-IP PORT(S) AGE
argocd-server-ip ClusterIP 10.105.209.116 10.1.4.51 80/TCP 3d18h
I use a self-hosted Forgejo instance (hosted in a Proxmox LXC) but you can use whatever you want. You need an API token with read permissions.
ArgoCD documents a declarative setup that we’ll use. For repositories we need to create a secret:
kubectl create secret generic home-ops-repo \
--namespace argocd \
--from-literal=type=git \
--from-literal=url=<repo-url> \
--from-literal=username=<username> \
--from-literal=password=<token> \
--dry-run=client -o yaml \
| kubeseal --cert infrastructure/sealed-secrets-cert.pem -o yaml \
> gitops/bootstrap/argocd-repo-secret.yaml
We also need to add the repository label to the generated file so it looks something like this:
apiVersion: bitnami.com/v1alpha1
kind: SealedSecret
metadata:
name: home-ops-repo
namespace: argocd
spec:
encryptedData:
password: ...
type: ...
url: ...
username: ...
template:
metadata:
name: home-ops-repo
namespace: argocd
labels:
argocd.argoproj.io/secret-type: repository
Apply it:
kubectl apply -f gitops/bootstrap/argocd-repo-secret.yaml
And the repo should show up in the UI or argocd repo list.
Instead of letting ArgoCD generate a random password we can set the initial password explicitly. It’s possible to do it either via the Helm chart or the declarative way, which I’ll continue with.
After generating it with kubeseal the secret should look like this:
apiVersion: bitnami.com/v1alpha1
kind: SealedSecret
metadata:
name: argocd-secret
namespace: argocd
spec:
encryptedData:
admin.password: ...
admin.passwordMtime: ...
server.secretkey: ...
template:
metadata:
labels:
app.kubernetes.io/name: argocd-secret
app.kubernetes.io/part-of: argocd
name: argocd-secret
namespace: argocd
With three keys:
password is a bcrypt hash:
argocd account bcrypt --password 'supersecret'
passwordMtime is the plaintext modification time:
date -u +"%Y-%m-%dT%H:%M:%SZ"
secretkey is a random value:
openssl rand -base64 48
ArgoCD uses server.secretkey to sign session and API tokens, and it will regenerate the key if restarted.
I kept getting logged out when I was rebuilding things all over the place and it was annoying so I added this to make it stable.
ArgoCD is now installed but at this point I think it’s good to take a step back before we steam ahead.
One of the benefits of ArgoCD is that you can create any kind of file structure and organize the repository any way you want. That’s also one of its problems: if you can do anything it’s hard to decide on a plan forward.
I found the blog post How to Structure Your Argo CD Repositories Using Application Sets illuminating and even though their setup is overkill for my homelab, I simplified their ideas into something that works for me.
The basic idea is to have all applications in their own folders under gitops/apps, and place all bootstrap stuff under gitops/bootstrap.
Borrowing the “level” terminology from the above post here’s the basic idea:
digraph
rankdir=TB
"root.yaml"
"appset.yaml"
bootstrap_more
apps_more
"root.yaml" -> "cilium_config.yaml"
"root.yaml" -> "argocd.yaml"
"root.yaml" -> "appset.yaml"
"root.yaml" -> bootstrap_more
"appset.yaml" -> "authentik"
"appset.yaml" -> "actualbudget"
"appset.yaml" -> "miniflux"
"appset.yaml" -> "homeassistant"
"appset.yaml" -> apps_more
// Keep the "…" boxes on the far right of each level.
}
Which loads in three phases:
root.yaml
bootstrap/ gets loaded
bootstrap/appset.yaml in turn loads all folders under apps/
This is what the directory structure looks like:
gitops
├── root.yaml # Level 1
├── bootstrap # Level 2
│ ├── cilium_config.yaml
│ ├── argocd.yaml
│ ├── appset.yaml
│ └── ...
└── apps # Level 3
├── authentik
│ └── ...
├── miniflux
│ └── ...
└── ...
I’m considering separating infrastructure and user workloads, but for now I’m fine with having everything in the apps/ folder.
root.yaml will be a master “app-of-apps” as it will be an Application that spawns other applications.
It looks like this:
apiVersion: argoproj.io/v1alpha1
kind: Application
metadata:
name: all-apps
namespace: argocd
annotations:
argocd.argoproj.io/sync-wave: "-100"
finalizers:
- resources-finalizer.argocd.argoproj.io
spec:
project: default
source:
repoURL: https://git.hietala.xyz/tree/home-ops.git
targetRevision: master
path: gitops/bootstrap
destination:
server: https://kubernetes.default.svc
namespace: argocd
syncPolicy:
syncOptions:
- CreateNamespace=true
automated:
prune: true
selfHeal: true
(You’re going to see lots of yaml files like this, better get used to it.)
The important bits are
The name all-apps in the argocd namespace
It should be synced before everyone else (sync-wave: -100)
Should load everything under gitops/bootstrap from my home-ops repo
I’ll be using pruning and self-healing to tell ArgoCD to keep everything in sync.
This is the entrypoint and it’s the last manual apply command we’ll use, as it’ll load everything else:
kubectl apply -f gitops/root.yaml
Inside the bootstrap/ folder lives, among other things, bootstrap/appset.yaml which is responsible for turning subfolders under apps/ into applications and loading them.
This is what I use and it’s an adaption of the code from the aforementioned blog post:
apiVersion: argoproj.io/v1alpha1
kind: ApplicationSet
metadata:
name: appset
namespace: argocd
spec:
# Use Go's engine so we can use basename/path
goTemplate: true
# The engine silently fails on missing keys by default
goTemplateOptions:
# Generate from all subfolders from my repo under apps/
generators:
- git:
repoURL: https://git.hietala.xyz/tree/home-ops.git
revision: master
directories:
- path: gitops/apps/*
template:
# The Application name will be folder + "-app"
# Creative, I know.
metadata:
name: "{{.path.basename}}-app"
spec:
project: default
# Describes the manifests of the application,
# which is everything under apps/<app-folder>/
source:
repoURL: https://git.hietala.xyz/tree/home-ops.git
targetRevision: master
path: "{{.path.path}}"
# Give the application its own namespace
destination:
server: https://kubernetes.default.svc
namespace: "{{.path.basename}}"
syncPolicy:
syncOptions:
- CreateNamespace=true
# Needed for larger manifests
- ServerSideApply=true
# ArgoCD does a dry run to check for resource kinds
# it doesn't yet recognize. This breaks some things
# so turn it off.
- SkipDryRunOnMissingResource=true
automated:
prune: true
selfHeal: true
I hope the comments make it clear how the above works.
The end result is that everything under apps/ will be loaded and organized under its own application.
Even though we manually apply some manifests during the bootstrap sequence, they can still be managed by ArgoCD after the fact.
This is solved by simply placing the relevant files in bootstrap/, and they’ll be managed going forward.
These are what we’ve collected so far:
Cilium
We installed Cilium using a helm install command.
Here’s a (hopefully) corresponding example of a manifest that ArgoCD can manage:
apiVersion: argoproj.io/v1alpha1
kind: Application
metadata:
name: cilium
namespace: argocd
annotations:
argocd.argoproj.io/sync-wave: "-1"
finalizers:
- resources-finalizer.argocd.argoproj.io
spec:
project: default
source:
repoURL: https://helm.cilium.io
chart: cilium
targetRevision: 1.19.2
helm:
values: |
kubeProxyReplacement: true
k8sServiceHost: 10.1.4.10
k8sServicePort: 6443
l2announcements:
enabled: true
externalIPs:
enabled: true
gatewayAPI:
enabled: true
ipam:
mode: kubernetes
operator:
replicas: 1
securityContext:
privileged: true
destination:
server: https://kubernetes.default.svc
namespace: kube-system
syncPolicy:
syncOptions:
- CreateNamespace=false
- ServerSideApply=true
automated:
prune: true
selfHeal: true
The duplication here is unfortunate but I couldn’t find a way around it.
At least we can reuse cilium_config.yaml with the load balancer IP specifications that we created during cluster setup.
Just plop it under bootstrap/ and we can move on.
Gateway API
Before installing Cilium we also applied the Gateway CRDs using this command:
kubectl apply -f https://github.com/kubernetes-sigs/gateway-api/releases/download/v1.2.1/standard-install.yaml
This is the corresponding YAML manifest (smaller as we don’t pass dozens of options):
apiVersion: argoproj.io/v1alpha1
kind: Application
metadata:
name: gateway-api
namespace: argocd
annotations:
argocd.argoproj.io/sync-wave: "-2"
finalizers:
- resources-finalizer.argocd.argoproj.io
spec:
project: default
source:
repoURL: https://github.com/kubernetes-sigs/gateway-api.git
targetRevision: v1.2.1
path: config/crd/standard
destination:
server: https://kubernetes.default.svc
namespace: kube-system
syncPolicy:
automated:
prune: true
selfHeal: true
Note that I specify sync-wave -2 for the Gateway and -1 for Cilium because the Gateway should sync before Cilium.
Sealed Secrets
We continue in the same manner of converting Helm installations to manifests:
apiVersion: argoproj.io/v1alpha1
kind: Application
metadata:
name: sealed-secrets
namespace: argocd
annotations:
argocd.argoproj.io/sync-wave: "-1"
finalizers:
- resources-finalizer.argocd.argoproj.io
spec:
project: default
source:
repoURL: https://bitnami-labs.github.io/sealed-secrets
chart: sealed-secrets
targetRevision: 2.16.2
helm:
values: |
fullnameOverride: sealed-secrets-controller
destination:
server: https://kubernetes.default.svc
namespace: sealed-secrets
syncPolicy:
syncOptions:
- CreateNamespace=true
automated:
prune: true
selfHeal: true
ArgoCD
In addition to the secrets used for the declarative setup (repository, admin password) ArgoCD can also manage itself:
apiVersion: argoproj.io/v1alpha1
kind: Application
metadata:
name: argocd
namespace: argocd
finalizers:
- resources-finalizer.argocd.argoproj.io
spec:
project: default
source:
repoURL: https://argoproj.github.io/argo-helm
chart: argo-cd
targetRevision: 10.1.4
helm:
values: |
configs:
secret:
createSecret: false
destination:
server: https://kubernetes.default.svc
namespace: argocd
syncPolicy:
syncOptions:
- CreateNamespace=false
automated:
prune: true
selfHeal: true
---
# Static IP as shown previously, `---` separates different kinds
This is the updated bootstrap recipe that I manage with Just:
[doc("Bootstrap everything from zero")]
full:
just bootstrap::cluster
just bootstrap::cilium
just secrets::restore_sealed_secrets_private_key
just bootstrap::sealed_secrets
just bootstrap::argocd
[doc("Bootstrap ArgoCD")]
[working-directory('../gitops')]
argocd:
helm install argocd \
--repo https://argoproj.github.io/argo-helm \
argo-cd \
--namespace argocd \
--create-namespace \
--version 10.1.4 \
--set configs.secret.createSecret=false
# We don't let ArgoCD generate the secret, we supply one:
kubectl apply -f bootstrap/argocd-secret.yaml
# Needed to be able to sync from repo.
kubectl apply -f bootstrap/argocd-repo-secret.yaml
# Wait for ArgoCD to deploy.
kubectl rollout status deployment/argocd-server -n argocd --timeout=300s
# Technically not needed as it'll get loaded on sync, this just speeds it up a little.
kubectl apply -f bootstrap/argocd.yaml --force-conflicts --server-side
# Sync everything else from the repo.
kubectl apply -f root.yaml

To complete our GitOps setup and to exercise our newfound capabilities let’s setup the Renovate service. Its purpose is to monitor git repositories and to create pull requests where it updates dependencies, see the above image for how it’s created a PR to update itself.
The singularity is here any second now.
To get Renovate setup we need to create three things:
Renovate will pick up RENOVATE_TOKEN in secrets, generated like so:
kubectl create secret generic renovate-token -n renovate \
--from-literal=RENOVATE_TOKEN='<token>' \
--dry-run=client -o yaml \
| kubeseal --cert infrastructure/sealed-secrets-cert.pem -o yaml \
> gitops/apps/renovate/renovate-secret.yaml
We’ll later add the name renovate-token to the main manifest.
Renovate has a bunch of configurations you can add. I’m good with the defaults except to modify the ArgoCD settings to support the custom directory structure I’m using:
},
}
}
This activates the argocd and kubernetes managers, where argocd watches ArgoCD specific manifests (Application/ApplicationSet) and kubernetes watches plain manifests (such as container images in Deployment).
The Application manifest is similar to the ones we’ve seen before:
apiVersion: argoproj.io/v1alpha1
kind: Application
metadata:
name: renovate
namespace: argocd
finalizers:
- resources-finalizer.argocd.argoproj.io
spec:
project: default
source:
repoURL: https://docs.renovatebot.com/helm-charts
chart: renovate
targetRevision: 46.236.3
helm:
values: |
existingSecret: renovate-token
cronjob:
schedule: "0 1 * * *"
renovate:
config: |
{
"platform": "forgejo",
"endpoint": "https://git.hietala.xyz",
"repositories": ["tree/home-ops"],
"gitAuthor": "Renovate Bot <[email protected]>",
"automerge": false
}
destination:
server: https://kubernetes.default.svc
namespace: renovate
syncPolicy:
syncOptions:
- CreateNamespace=true
automated:
prune: true
selfHeal: true
Note that we configure the secret (renovate-token), how often it should run, and the repository information.
Commit and push and it should be ready to go. ArgoCD has a really good UI where you can see what’s happening, if you made a mistake somewhere, and also trigger the cron job manually.
Or you can use kubectl, for example:
# See the health status of all applications
kubectl get applications -A
# Trigger the cron job
kubectl create job -n renovate --from=cronjob/renovate renovate-manual
# Watch the logs
kubectl logs -n renovate -l job-name=renovate-manual -f
With this we’ve got our first example of managing a Kubernetes service via ArgoCD. We’ll continue to see more examples of this as the series continues.
2026-06-26 08:00:00

I recently got a Pebble Time 2 as it seemed like a fun smartwatch away from Google/Apple/Samsung with a good 4 weeks of battery life. One thing I wanted to do is to create a custom watchface for my specific problems.
It took more effort to design the watch than I had anticipated and there’s a deceptively large amount of thought that has gone into some of the features here, so I thought it’d be interesting to write a little about it.
It’s difficult to describe what I’ve been going through the last ~6 months. It started with a normal depression (yeah, a “normal depression” says a lot already) but it was followed by what felt like a big increase in volume; manageable issues I’ve had forever were suddenly overpowering.
I’ve always found it difficult to start chores and boring tasks, yet now it felt like trying to swim through quicksand.
At the same time I had huge problems with hyper focus. I could start working on something and the entire day would just disappear; I would sit in front of the computer for 8 hours without any break, interrupted by having to go get my kids from school and I realize that my bladder is exploding and I hadn’t eaten lunch yet. Then I would spend the rest of the day thinking about it, even having difficulty falling asleep because I’m still thinking about it.
Needless to say, my time management has gone out the window, even missing appointments for the first time ever.
Of course, a watchface won’t solve any of these but I’m desperate and nothing I’m trying is helping. Concretely I’d like the watch to help me with these things:
I was going to name the watchface “ADHD hero” but that was a little on the nose as I don’t have an ADHD diagnosis (yet… The investigation is under way but it’s not a quick process).
I like open source but I don’t have any plans of publishing the watchface. The entire point of the watchface is to remove friction in my life, not create another source of anxiety. This way I’m free to change it, rewrite it, pollute it with vibes, and I can focus on making it just good enough for me without having to worry about others.
I encourage you to steal anything you like and create your own watchface; it’s straightforward with Pebble.
I’m a big believer in being able to quickly iterate through different prototypes. I’ve had success with using Claude to generate HTML prototypes and it can spit out images like this:
Design-wise it’s hit-and-miss and Claude seems to lack creativity and taste (but sometimes it comes up with great ideas). You need to guide it properly and I’ve gotten good usage from telling it to generate dozens of variations of a concept and iterating from there. All images in this post are generated in this way at various points of the development.
It can also quickly generate interactive prototypes, like this embedded widget that simulates (almost) the entire watchface and its features:
This is super useful and I’ve used it to fine tune the wedge geometry, color scheme, a chiptune library, and more.
The design of the watchface started before I had the watch and before I wrote a line of code. Initially the feel was very different; it was a cleaner, more uniform, design without as much clutter and everything followed a consistent design language.
I just knew that it was perfect.
When the watch arrived I built it and it sucked. It was so boring…
I saw the Comic Drop watchface and its comic book panel layout is really interesting. Wouldn’t it be cool to have a similar comic book panel layout that would show the events during the day? Maybe they could be dynamic and move around during the day?
It would be really neat to have some pixel graphics of a computer during the morning when I should work, a picture of a barbell when I should exercise, etc. Unfortunately that’s beyond my skill level at the moment so I had to give up that idea.
So I went back to the original design but iterated on a “comic book” style with thicker lines, brighter colors, and more irregularities:
This is a more interesting watchface and it looks much better on the wrist than the first iteration. Not perfect of course but it catches my interest, which is the point.
I want to be able to glance at the watch to see my calendar. Watchfaces such as the Sectograph add event information inside the clock (so an event between 1 and 2 would colorize the section of the analog clock) and I wanted something similar.
Here are some prototypes to give you an idea of what I’m talking about:
This should hopefully make it easier for me to be able to plan my day a little better.
A notable omission in my design is the lack of a text description of the event. That’s nice but it also clutters the design I’m going for. The important thing for me isn’t what the event is, it’s “is there an event at all”.
My brain is very good at filtering away static and uninteresting information. For example, I’ve tried to have Calendar, Habitica, and Todoist widgets on my phone to help me keep track of my events/habits/tasks… But it didn’t take long for my brain to start completely ignoring them despite them taking up 80% of the screen. When I say ignore I mean that literally: I completely stopped noticing them.
So what I wanted to try was to spice it up a little by randomizing the event geometry. See these two examples:
To me the non-uniform prototype is more visually interesting and the changing geometry should hopefully prevent me from filtering them out.
You can argue that the rounder uniform version would look better on the Pebble Round 2, which is fair. Here’s a similar mockup but with the round’s geometry:
I still think the non-uniform variant catches the eye more and solves my specific problem a bit better but there’s no denying that a round layout on a round watch fits very well.
Another idea I had was instead of statically fixing events to their times I wanted the events to move towards “now” at 12 o’clock. So an event that renders at 3 o’clock is 3 hours away and an event that overlaps 12 is ongoing.
While this prevents me from easily looking back at my day (past events disappear) it makes it easier to feel the urgency as events draw closer. A bit weird perhaps but in practice it feels great.
Here’s the same event through its lifecycle: drifting in from 3 o’clock, ongoing as it crosses 12 (with the countdown band draining in the clock), then gone once it’s in the past.
Something like this:
This works pretty well but there’s a problem: how to differentiate between an event that will start in one minute and that has been going on for a while? Because the event gets cut off at 12 there’s no way to tell at a glance.
At first I tried to enlarge the event wedge so it touches the clock if it’s ongoing. That was a bit weird so instead I tried to colorize the clock outline with the event that’s ongoing. Then I realized, why not have the outline count down the remaining time of the event, similar to a Time Timer?
In practice this works out wonderfully well.
I quite like the completion countdown for calendar events. This kind of design is often associated with tracking tasks (such as in Pomodoro) so why not add it to the watch too?
For example, I might want to configure a stretch of 60 min work / 15 min break / 60 min work before I should stop working. Pretty standard stuff. As I’m struggling to get started I’ll add a twist: a working block should have a 5-minute warm-up period. So the timeline looks like this:
I think it makes sense to have that one count down (from filled to empty) while the working block counts up (from empty to filled), like so:
This is a post about design, not about implementation, but I still need to address how we can implement a work timer toggle.
The issue is that a Pebble watchface is a background process that cannot interact with Pebble’s physical buttons. You can bind the buttons to quick launch Pebble apps but not watchfaces. A watchface can detect steps and taps on the watch but it cannot communicate with other apps.
There’s a few ways to get around this:
Toggle using taps only.
Implement it as a Pebble app, not a watchface.
Add a new Pebble app (tied to a button) that round trips commands via the companion app back to the watchface.
digraph
rankdir=TB
button
app
phone
face
button -> app
app -> phone
phone -> face
app -> face
}
I first tried to implement toggling using taps but it was too unreliable. If I went the app route I’d have to relaunch it all the time, which represents unacceptable friction. What’s left is the janky phone round trip solution.
I already need a companion app for the calendar sync but it still makes me queasy.
I’ve tried alarms, Pomodoro, and similar before but they all share the same failure mode: I simply ignore the alarm and continue with what I was doing.
A Pomodoro can’t be interrupted; it marks 25 minutes of pure work.
I don’t know, it could be 2 minutes just as likely as 200 minutes.
So I figured I’ll try to force myself to take breaks by making it annoying to clear (a bit like the Hand Grenade Alarm clock but in wrist form). The watchface can vibrate, play sound, react to steps, and detect taps and this is the flow I came up with:
The alarm transforms the screen and vibrates and plays a tune until it’s dismissed (repeats every 30 seconds).
Visually the required steps are drawn at the border (no events are shown during an alarm) and the alarm is drawn as a comic-style blast (tapping it shrinks the blast). Like this:
It’s a pretty cool setup and it is helping but it still fails quite often.
Most of the time I forget to start the work timer and other times I’m able to add enough steps by waving my hand around like a crazy person, and then continue working. (If I raise the step count then it gets annoying to dismiss when I’m actually walking.)
Could be tuned better I suppose.
Visually there are some distinct alarms I use:
I’ve separated them visually and they each use unique vibrations and tunes.
I’ve previously used watchfaces overloaded with all kinds of information and graphs that were cool for a few minutes and then I never looked at them again. But there are some things I regularly glance at such as the date or if the watch has lost its phone connection.
Here are some prototypes for dates that I liked but ultimately discarded:
The difficulty is to not crowd the watchface too much with the date and the other elements I’ll get to later.
In the end I ended up with a simple 31/10 label and a small weekly text without background as I don’t look at the week number that often:
I’m not completely sold on the week number display (yellow labels were a lot more fun) but it’s good enough I guess.
It was really difficult for me to find a step counter design I liked. Everything I tried just felt off. Here are three of my attempts:
I used to track steps at the edges for most of the design (you can see the step elements in many of the prototype images in this post) but in the end it introduced a bit too much clutter for my taste. While the other two attempts above are crude and could be polished more I didn’t see potential in them. (Examining and cutting away branches is important during design I think.)
And then the revelation: I don’t care about the exact step count. The only purpose of showing steps is to help me keep the step count up, so I’m not sitting in front of the computer all day, and having badges for tiers solves that problem even better.
Here are the badges I use:
Bronze is achieved at 33% of my daily goal, silver at 66%, gold at 100%, and diamond is for over-achieving with 150%. I feel that having an easy to reach tier helps me do something on the lazy days and the hard-to-reach tier helps give me an extra kick the days I’m out and about.
Yes, not knowing exactly how much is left to the next tier is slightly annoying, but for those cases opening up the Health app on the watch to view the step count is good enough.
High after the rush of figuring out how to display steps let’s use the same approach for battery warnings:
Yes, I probably should make the little battery interactive or something but this is a 80:20 situation: I get 80% of the benefit from 20% of the effort. It’s good enough.
More badges!
Pebble has a “Quiet Time” mode that silences notifications. This is a separate thing from Android’s sleep mode or do-not-disturb mode. As I have a companion app I can detect the Android state and the watchface combines the two (and silences task alarms for instance) while also showing a “sleep” bubble so I can see these invisible states.
I’m a big fan of some of the ideas of Getting Things Done such as the idea of the “next task”: the one actionable thing that you should do next.
I’ve used the Todoist Android widget on my phone to display my tasks but I wanted to incorporate it into my watch too. The idea is to only display the next task, something like this:
While I think all three prototypes above are visually pleasing I went with the wider display that takes up less vertical space:
As a bonus I don’t have to mess around with rendering angled text.
It works fairly well but there’s a wrinkle: when I’ve done the task or realize I can’t do it now I have to pull up my phone and that’s annoying so in practice I’m leaving a stale task alone forever, priming my brain to ignore it.
I’ve tried the Todoist Mini app on the Pebble but there’s so many clicks to find the task and complete or reschedule it.
What I did was add it to the app I’m already using to toggle the work timer and give it a menu of options:

I can still toggle the work timer, complete the next task, or reschedule the task (it’s rescheduled for tomorrow).
I’ve bound it to the UP button via “quick launch”. To keep toggling the work timer as simple as possible pressing UP again toggles the timer (so double clicking UP when the watchface is visible toggles the timer). Otherwise it uses the standard Pebble control scheme (UP/DOWN to select and SEL to execute) and the app closes and returns to the watch when an item is selected.
So far it’s been working really well but as has always been the case before with these productivity “fixes”; I always tire of them eventually.
As a little extra I added some markers to track other types of things along the edges:
The colored dots for calendar events are genuinely useful as many events require some preparation/travel time.
In these cases I add a reminder for the event at, say, 15 minutes before.
With the dots this preparation time can now also be seen by glancing at the watch
(Pebble natively shows reminder alerts anyway.)
The end of day marker is also useful for when I’m planning my working day but sunrise
/ sunset
is mostly a gimmick, and I don’t use the manual alarms
.
At the end I got an interesting watchface I think looks fun and that helps me go about my day. It’s pretty easy to make one, and if you don’t know where to begin LLMs can shorten the time to get up and running.
2026-05-31 08:00:00
When I’ve read other series about Kubernetes and reach the secrets section my eyes glaze over. I can’t help myself; I want to read about the fun stuff. Secrets are necessary to be sure, but it’s a little boring…
But if I want to do proper GitOps I need to manage secrets (and to document the process). The sooner I set it up the better.
Kubernetes has different solutions for secrets management. Of particular note is Sealed Secrets which creates files that are safe to commit to git and Kubernetes decrypts them in-cluster.
This is pretty great but has one big drawback: it can only manage secrets inside Kubernetes.
It cannot be used to encrypt things like the talosconfig or the Proxmox password Terraform uses.
That’s why I’ll also use SOPS + Age, which allows us to encrypt whatever file we want. The idea is to use SOPS + Age to manage the bootstrapping secrets and let Sealed Secrets take over when ArgoCD is up. This way there’s only one private key I need to manage and the rest is available from the git repo.
First, we need to install sops and age locally (I found them in my package manager).
Then we can generate our private key:
mkdir -p ~/.config/sops/age
age-keygen -o ~/.config/sops/age/keys.txt
You don’t want to lose this key, store it somewhere safe. I stored it in Bitwarden (although I’m migrating to Vaultwarden hosted in-cluster, which is a bit weird as keeping the key there risks a lock-out).
You then need a .sops.yaml that describes the files to encrypt/decrypt.
For example, this is an entry for talosconfig.yaml:
creation_rules:
- path_regex: infrastructure/talosconfig(\.encrypted)?.yaml$
age: age1rrkgd5yza053qk9m8lp0ww39apdarz7w0rjyq85493g8l9gufgnq9cehzx
encrypted_regex: '^(ca|crt|key)$'
(age contains the public key, safe to share.)
With encrypted_regex you can limit encryption to certain fields; if you leave it out you’ll encrypt the entire file.
Then to encrypt and decrypt talosconfig.yaml we generated in a previous post:
sops --encrypt talosconfig.yaml > talosconfig.encrypted.yaml
sops --decrypt talosconfig.encrypted.yaml > talosconfig.yaml
talosconfig.encrypted.yaml is safe to commit to git but the cleartext file talosconfig.yaml should be added to .gitignore.
It won’t take long for me to forget these commands so I’ll add them to Just.
These recipes will take care of the secrets we’ve handled so far and the cleartext files (talosconfig, kubeconfig, secrets.auto.tfvars, terraform.tfstate) should allow us to regain cluster control, or to bootstrap the cluster anew from the git repo and the sops key.
[doc("Decrypt required files committed to git")]
decrypt_required:
just secrets decrypt_cluster_config
just secrets decrypt_terraform_secrets
just secrets decrypt_terraform_state
[doc("Encrypt talosconfig and kubeconfig")]
[working-directory('../infrastructure')]
encrypt_cluster_config:
sops --encrypt kubeconfig.yaml > kubeconfig.encrypted.yaml
sops --encrypt talosconfig.yaml > talosconfig.encrypted.yaml
[doc("Decrypt talosconfig and kubeconfig")]
[working-directory('../infrastructure')]
decrypt_cluster_config:
sops --decrypt talosconfig.encrypted.yaml > talosconfig.yaml
sops --decrypt kubeconfig.encrypted.yaml > kubeconfig.yaml
chmod 600 talosconfig.yaml kubeconfig.yaml
[doc("Encrypt secrets.auto.tfvars")]
[working-directory('../infrastructure')]
encrypt_terraform_secrets:
sops --encrypt secrets.auto.tfvars > secrets.auto.encrypted.tfvars
[doc("Decrypt secrets.auto.tfvars")]
[working-directory('../infrastructure')]
decrypt_terraform_secrets:
sops --decrypt secrets.auto.encrypted.tfvars > secrets.auto.tfvars
chmod 600 secrets.auto.tfvars
[doc("Encrypt terraform.tfstate")]
[working-directory('../infrastructure')]
encrypt_terraform_state:
sops --encrypt --input-type json --output-type json terraform.tfstate > terraform.encrypted.tfstate
[doc("Decrypt terraform.tfstate")]
[working-directory('../infrastructure')]
decrypt_terraform_state:
# If the encrypted state doesn't exist yet (fresh repo), skip silently.
test -f terraform.encrypted.tfstate || exit 0
sops --decrypt --input-type json --output-type json terraform.encrypted.tfstate > terraform.tfstate
chmod 600 terraform.tfstate
I also added encryption to create_cluster_config bootstrap command, to make
it harder for me to forget to add them to the repo:
[doc("Create talosconfig and kubeconfig")]
[working-directory('../infrastructure')]
create_cluster_config:
terraform output -raw talosconfig > talosconfig.yaml
terraform output -raw kubeconfig > kubeconfig.yaml
just secrets::encrypt_cluster_config
And for the terraform state too (run with just tf::apply instead of a plain terraform apply):
[doc("terraform apply, then re-encrypt state (encrypts even on failure)")]
[working-directory('../infrastructure')]
apply *args:
#!/usr/bin/env bash
set -e
trap 'just secrets::encrypt_terraform_state' EXIT
terraform apply {{args}}
[doc("terraform destroy, then re-encrypt state (encrypts even on failure)")]
[working-directory('../infrastructure')]
destroy *args:
#!/usr/bin/env bash
set -e
trap 'just secrets::encrypt_terraform_state' EXIT
terraform destroy {{args}}
Let’s move on to sealed secrets. There are more setup steps than with SOPS + Age but it’s not that bad.
I’ll install the sealed secrets controller using helm:
helm install sealed-secrets \
--repo https://bitnami-labs.github.io/sealed-secrets \
sealed-secrets \
--version 2.16.2 \
--namespace sealed-secrets \
--create-namespace \
--set fullnameOverride=sealed-secrets-controller
# Wait for it to deploy
kubectl rollout status deployment/sealed-secrets-controller -n sealed-secrets
To create secrets on the client we also need the kubeseal command.
It wasn’t available on the Void Linux package manager, so let’s do the hard way:
set -x KUBESEAL_VERSION '0.36.1'
curl -OL "https://github.com/bitnami-labs/sealed-secrets/releases/download/v$KUBESEAL_VERSION/kubeseal-$KUBESEAL_VERSION-linux-amd64.tar.gz"
tar -xvzf kubeseal-$KUBESEAL_VERSION-linux-amd64.tar.gz kubeseal
sudo install -m 755 kubeseal /usr/local/bin/kubeseal
rm kubeseal-$KUBESEAL_VERSION-linux-amd64.tar.gz
To create a secret we can either use the cluster (needs an active connection) or offline via a certificate.
I prefer the certificate simply because you need to pass fewer arguments (--cert vs --controller-name and --controller-namespace).
Here’s how to fetch the certificate:
kubeseal --fetch-cert \
--controller-name=sealed-secrets-controller \
--controller-namespace=sealed-secrets \
> infrastructure/sealed-secrets-cert.pem
(It’s a public key, safe to commit to git.)
And this is how we can use it to generate a secret my-secret with the two fields username and password:
kubectl create secret generic my-secret \
--namespace some-namespace \
--from-literal=username="user" \
--from-literal=password="password1" \
--dry-run=client -o yaml \
| kubeseal --cert infrastructure/sealed-secrets-cert.pem \
--format yaml \
> gitops/apps/myapp/my-secret.yaml
(There might be other ways to do this. You can generate json files for example, but this works and I don’t care to do research.)
It will be stored in gitops/apps/myapp/my-secret.yaml that we can apply:
kubectl apply -f gitops/apps/myapp/my-secret.yaml
In the future when we get our GitOps setup up the process is the same except we don’t apply the secret; just create, commit, and push and it’ll get applied automatically.
It may feel like a lot of effort, but it’s quite nice to work with in day-to-day operations.
The process to update a secret is exactly the same; update the file with new contents and reapply.
Check that the secret has been applied:
kubectl get secret my-secret -n some-namespace -o yaml
The data fields username and password shown above will be base64 encoded.
Here’s how to print out the password in cleartext:
kubectl get secret my-secret -n some-namespace -o jsonpath='{.data.password}' | base64 -d
Or you can view the secrets in a dashboard such as Headlamp, which is arguably easier.
There’s one gotcha to sealed secrets: when the controller is installed it will generate a new public/private key pair so all existing sealed secrets are invalidated. We’d have to reseal all secrets after we reset the cluster, which is highly annoying.
We can circumvent this by exporting the private key, encrypt it with SOPS + Age, and store it in git. Then during the bootstrap process we can import the private key to the controller, allowing it to reuse all existing sealed secrets.
First export the key and encrypt it so we can keep it in git (gitignore sealed-secrets-key.yaml):
kubectl get secret -n sealed-secrets \
-l sealedsecrets.bitnami.com/sealed-secrets-key=active -o yaml \
> sealed-secrets-key.yaml
sops --encrypt sealed-secrets-key.yaml > sealed-secrets-key.encrypted.yaml
This needs a .sops.yaml rule:
creation_rules:
- path_regex: infrastructure/sealed-secrets-key(\.encrypted)?.yaml$
age: age1rrkgd5yza053qk9m8lp0ww39apdarz7w0rjyq85493g8l9gufgnq9cehzx
Then to import it we simply apply it:
kubectl apply -f sealed-secrets-key.yaml
We’ve added a few steps to the bootstrap process to setup the sealed secrets controller:
[doc("Bootstrap everything from zero")]
full:
just bootstrap::cluster
just bootstrap::cilium
just secrets::restore_sealed_secrets_private_key
just bootstrap::sealed_secrets
[doc("Restore sealed-secrets-key.yaml")]
[working-directory('../infrastructure')]
restore_sealed_secrets_private_key:
# If the encrypted private key doesn't exist, skip the whole recipe.
test -f sealed-secrets-key.encrypted.yaml || exit 0
just secrets::decrypt_sealed_secrets_private_key
# Don't exit if namespace already exists.
kubectl create namespace sealed-secrets || true
# Restore the private key.
kubectl apply -f sealed-secrets-key.yaml
rm sealed-secrets-key.yaml
# We may need to restart the controller, but it may not exist, which is fine.
kubectl rollout restart deployment/sealed-secrets-controller -n sealed-secrets || true
I’ve tried to safeguard the recipe to not crash if we haven’t created a key or bootstrapped the controller yet.
[doc("Install sealed secrets controller")]
sealed_secrets:
helm install sealed-secrets \
--repo https://bitnami-labs.github.io/sealed-secrets \
sealed-secrets \
--version 2.16.2 \
--namespace sealed-secrets \
--create-namespace \
--set fullnameOverride=sealed-secrets-controller
# Wait for it to deploy
kubectl rollout status deployment/sealed-secrets-controller -n sealed-secrets
And some extra management recipes:
[doc("Fetch sealed-secrets-cert.pem, necessary to encrypt secrets offline")]
[working-directory('../infrastructure')]
fetch_sealed_secrets_cert:
kubeseal --fetch-cert \
--controller-name=sealed-secrets-controller \
--controller-namespace=sealed-secrets \
> sealed-secrets-cert.pem
[doc("Fetch sealed-secrets-key.yaml")]
[working-directory('../infrastructure')]
fetch_sealed_secrets_private_key:
kubectl get secret -n sealed-secrets -l sealedsecrets.bitnami.com/sealed-secrets-key=active -o yaml > sealed-secrets-key.yaml
just secrets::encrypt_sealed_secrets_private_key
rm sealed-secrets-key.yaml
[doc("Encrypt sealed-secrets-key.yaml")]
[working-directory('../infrastructure')]
encrypt_sealed_secrets_private_key:
sops --encrypt sealed-secrets-key.yaml > sealed-secrets-key.encrypted.yaml
[doc("Decrypt sealed-secrets-key.yaml")]
[working-directory('../infrastructure')]
decrypt_sealed_secrets_private_key:
sops --decrypt sealed-secrets-key.encrypted.yaml > sealed-secrets-key.yaml
With this we’re prepared to setup GitOps with ArgoCD in the next post.
2026-05-28 08:00:00
I wanted the bootstrap process to be simple; ideally a single command and it would be up and running. But that’s not what we have right now; just look at this monstrosity from the previous post:
helm install cilium cilium/cilium \
--namespace kube-system \
--version 1.19.2 \
--set kubeProxyReplacement=true \
--set k8sServiceHost=10.1.4.10 \
--set k8sServicePort=6443 \
--set l2announcements.enabled=true \
--set externalIPs.enabled=true \
--set gatewayAPI.enabled=true \
--set ipam.mode=kubernetes \
--set operator.replicas=1 \
--set securityContext.privileged=true
Yuck.
I could place this in a README file, put it in a shell script, a Makefile, Task, or many other tools but I chose Just. Just has some small quality of life features and it doesn’t make me want to hurt myself when I look at it.
If all I wanted was a simple bootstrap script I wouldn’t bother writing a separate post about it, but I realized that Just is an excellent way to add some sorely needed documentation as well.
For example, in the future I’ll probably forget how the bootstrap process looks like.
Fire up just:
$ just
just -l
Available recipes:
argocd ...
arr ...
bootstrap ...
cluster ...
deps ...
garage ...
jellyfin ...
proxmox ...
repos ...
router ...
secrets ...
tf ...
util ...
(Don’t worry, I won’t bore you with the details of everything here.)
Let’s drill into the bootstrap:
$ just bootstrap
just -l bootstrap
Available recipes:
argocd # Bootstrap ArgoCD
cilium # Bootstrap Cilium
cluster # Bootstrap VMs and talos cluster
create_cluster_config # Create talasconfig and kubeconfig
forgejo # Install and configure Forgejo inside the LXC created by Terraform
full # Bootstrap everything from zero
link_cluster_config # Initialize ~/.talos/config and ~/.kube/config
pbs # Install and configure PBS inside the LXC created by Terraform
sealed_secrets # Install sealed secrets controller
It seems just bootstrap full would try to bootstrap everything. Sounds scary, so let’s look at the code:
[doc("Bootstrap everything from zero")]
full:
just bootstrap::cluster
just bootstrap::cilium
# We'll revisit these in the future (I hope)
just secrets::restore_sealed_secrets_private_key
just bootstrap::sealed_secrets
just bootstrap::argocd
# I manage some other Proxmox related things too,
# but they're out of scope for this series
just bootstrap::pbs
just bootstrap::forgejo
Where the cluster is bootstrapped like this:
[doc("Bootstrap VMs and talos cluster")]
[working-directory('../infrastructure')]
cluster:
just tf::init
just tf::apply -auto-approve
just bootstrap::create_cluster_config
[doc("Create talasconfig and kubeconfig")]
[working-directory('../infrastructure')]
create_cluster_config:
terraform output -raw talosconfig > talosconfig.yaml
terraform output -raw kubeconfig > kubeconfig.yaml
# We'll look at this in the next post
just secrets::encrypt_cluster_config
And Cilium like so:
[doc("Bootstrap Cilium")]
[working-directory('../gitops')]
cilium: wait_for_api
kubectl apply -f https://github.com/kubernetes-sigs/gateway-api/releases/download/v1.2.1/standard-install.yaml
helm install cilium \
--repo https://helm.cilium.io/ \
cilium \
--namespace kube-system \
--version 1.19.2 \
--set kubeProxyReplacement=true \
--set k8sServiceHost=10.1.4.100 \
--set k8sServicePort=6443 \
--set l2announcements.enabled=true \
--set externalIPs.enabled=true \
--set gatewayAPI.enabled=true \
--set ipam.mode=kubernetes \
--set operator.replicas=1 \
--set securityContext.privileged=true
kubectl rollout status daemonset/cilium -n kube-system
kubectl apply -f bootstrap/cilium_config.yaml
talosctl health -n 10.1.4.10 --wait-timeout 10m
Here I’ve added the bootstrap commands from the last post together with some waits to make the process work in script form.
There are some other features and gotchas with Just I’d like to mention.
Note the dependency wait_for_api above in the line cilium: wait_for_api.
This means wait_for_api will run before cilium, which waits until the Kubernetes nodes are ready (albeit not necessarily healthy according to talosctl health):
[private]
wait_for_api:
until kubectl get nodes 2>/dev/null | grep -q "NotReady\|Ready"; do sleep 5; done
You can call just bootstrap::wait_for_api inside scripts too, which would give you more control over ordering.
Dependencies on the other hand are deduplicated and always run before the recipe.
I wanted to organize the recipes a little and group them (just tf::init instead of just tf_init).
For that we use modules:
mod argocd 'just/argocd.just'
mod bootstrap 'just/bootstrap.just'
mod secrets 'just/secrets.just'
# etc...
# With this `just` will give you a list of the modules.
[private]
default:
just -l
Organized like this in the repository:
home-ops/
├── justfile # `just` entrypoint, loads `just/*`
├── just/
│ ├── bootstrap.just
│ ├── secrets.just
│ ├── cluster.just
│ └── ...
├── infrastructure/ # Terraform, setup in the previous post
└── gitops/ # GitOps using ArgoCD, setup in the future
Another nice feature is to be able to set the working directory:
[doc("terraform init")]
[working-directory('../infrastructure')]
init *args:
terraform init {{args}}
This means I can run just tf::init from anywhere in the entire repo, and it will just work.
export KUBECONFIG := justfile_directory() / "infrastructure/kubeconfig.yaml"
export TALOSCONFIG := justfile_directory() / "infrastructure/talosconfig.yaml"
Every kubectl/talosctl command in any just file now targets the cluster, no matter what directory they’re run from.
By default each line runs in its own shell.
A #!/usr/bin/env bash at the top converts a recipe into one script, which allows you to define functions, run for loops, or set traps for cleanup.
For example, this recipe runs terraform apply and then encrypts the terraform state even on failure:
[doc("terraform apply, then re-encrypt state (encrypts even on failure)")]
[working-directory('../infrastructure')]
apply *args:
#!/usr/bin/env bash
set -e
trap 'just secrets::encrypt_terraform_state' EXIT
terraform apply {{args}}
(I encrypt the state so I can safely commit it to git, meaning I can manage terraform from multiple computers, since you need the up-to-date state.)
just bootstrap full
I wanted to have a single command to bootstrap and I think I’m pretty close. In practice it’s not quite that simple; for example secret management may need some extra setup first, which we’ll revisit in the next post.