<?xml version="1.0" encoding="UTF-8"?><rss version="2.0"
	xmlns:content="http://purl.org/rss/1.0/modules/content/"
	xmlns:wfw="http://wellformedweb.org/CommentAPI/"
	xmlns:dc="http://purl.org/dc/elements/1.1/"
	xmlns:atom="http://www.w3.org/2005/Atom"
	xmlns:sy="http://purl.org/rss/1.0/modules/syndication/"
	xmlns:slash="http://purl.org/rss/1.0/modules/slash/"
	>

<channel>
	<title>Mutana</title>
	<atom:link href="https://mutana.xyz/feed/" rel="self" type="application/rss+xml" />
	<link>https://mutana.xyz</link>
	<description>Hello World</description>
	<lastBuildDate>Fri, 28 Aug 2026 15:09:27 +0000</lastBuildDate>
	<language>fr-FR</language>
	<sy:updatePeriod>
	hourly	</sy:updatePeriod>
	<sy:updateFrequency>
	1	</sy:updateFrequency>
	<generator>https://wordpress.org/?v=7.1</generator>
	<item>
		<title>Actual Budget on Kubernetes: Personal Finance in My Homelab</title>
		<link>https://mutana.xyz/actual-budget-on-kubernetes-personal-finance-in-my-homelab/</link>
		
		<dc:creator><![CDATA[]]></dc:creator>
		<pubDate>Fri, 28 Aug 2026 15:09:23 +0000</pubDate>
				<category><![CDATA[Notes]]></category>
		<guid isPermaLink="false">https://mutana.xyz/?p=78</guid>

					<description><![CDATA[Why my budgeting application is simple to run, important to back up, and deliberately kept close to home.]]></description>
										<content:encoded><![CDATA[<p>Actual Budget is one of the least dramatic applications in my cluster, which is exactly how I want personal finance software to behave.</p>
<p>It gives me a private place to organise accounts, transactions, categories and budgeting history. The workload is small. The data is not. If the application disappears for an afternoon, nothing else in the homelab falls over. If its persistent data disappears, years of useful context can disappear with it.</p>
<p>That difference shapes the way I operate it.</p>
<pre><code class="language-text">Browser
   │
   ▼
Traefik
   │
   ▼
Actual Budget server
   │
   ▼
PVC-backed data directory</code></pre>
<p>Traefik handles the HTTPS entry point. Actual Budget runs as a single Kubernetes Deployment and keeps its state on a persistent volume. The result is intentionally compact: one application pod, one Service, one route and one PVC.</p>
<h2>Why self-host a budget?</h2>
<p>Financial data is unusually good at revealing the shape of a life. It contains routines, places, subscriptions, mistakes and plans. I do not need a complicated threat model to prefer keeping that history on infrastructure I control.</p>
<p>Self-hosting also makes the boundary clear:</p>
<pre><code class="language-text">application can be recreated from Git
financial history must be restored from backup</code></pre>
<p>The first half is a deployment problem. The second is a data-protection problem. Kubernetes helps with the first half, but it does not magically solve the second.</p>
<h2>Deployment layout</h2>
<p>The manifests use a reusable base and a cluster-specific overlay:</p>
<pre><code class="language-text">actualbudget/
├── base/
│   ├── deployment
│   ├── service
│   └── namespace
├── overlays/
│   └── local/
│       ├── ingress route
│       └── persistent volume claim
└── fleet.yaml</code></pre>
<p>Kustomize builds the final resources and Rancher Fleet reconciles the overlay. The application lives in its own <code>actualbudget</code> namespace and currently runs as one replica.</p>
<p>The persistent claim is <code>2 GiB</code> on local-path storage. That is modest, but capacity is not the interesting limit here. The important questions are whether the volume is mounted, whether it contains the expected database, and whether a usable copy exists somewhere outside the failure domain of that volume.</p>
<h2>Authentication and exposure</h2>
<p>The public request path terminates at Traefik. Authelia can sit in front of the route as an additional access-control layer, while Actual Budget still owns its application-level session and data model.</p>
<p>I treat those as separate controls:</p>
<pre><code class="language-text">Traefik and Authelia decide who reaches the application
Actual Budget decides what happens inside the application</code></pre>
<p>A healthy ingress is not proof that the budget is usable. After an access-control change I open the real application, complete the authentication flow and load an account. That end-to-end check covers much more than an HTTP 200 from the proxy.</p>
<h2>Operating Actual Budget</h2>
<p>When the service is unavailable, I start with the complete Kubernetes path:</p>
<pre><code class="language-bash">kubectl -n actualbudget get deploy,pod,svc,pvc,ingressroute
kubectl -n actualbudget describe deploy actualbudget
kubectl -n actualbudget logs deploy/actualbudget --tail=200</code></pre>
<p>Then I check the volume:</p>
<pre><code class="language-bash">kubectl -n actualbudget get pvc
kubectl -n actualbudget describe pvc actualbudget</code></pre>
<p>The most useful failure split is simple:</p>
<pre><code class="language-text">pod Pending
  └── scheduling or volume binding

pod restarting
  └── application startup, permissions or damaged state

pod Ready, page unavailable
  └── Service, Traefik or authentication path

page loads, data missing
  └── wrong or empty volume mounted</code></pre>
<p>That last case deserves care. Restarting a pod attached to an empty volume only produces a healthy empty application more efficiently.</p>
<h2>Backup and recovery</h2>
<p>The useful backup unit is the application data directory, not the Deployment manifest. Git already contains the resources needed to recreate the pod and route.</p>
<p>My recovery sequence is:</p>
<pre><code class="language-text">1. Restore the persistent data
2. Restore any required runtime secret inputs
3. Reconcile the local overlay
4. Wait for the pod and volume to become ready
5. Sign in and open real budget data</code></pre>
<p>The service is a lower operational tier than ingress or authentication, so a short outage is acceptable. Data loss is not. The target is a daily snapshot and a recovery measured in hours rather than minutes.</p>
<p>A snapshot is only a recovery mechanism after it has been restored at least once. For this service, a good restore test is not merely seeing files on disk. It is opening the application and confirming that recent accounts, transactions and categories are coherent.</p>
<h2>Upgrades and the <code>latest</code> tag</h2>
<p>The current overlay tracks the <code>latest</code> application image. That is convenient, but it also means the exact application version is not fully expressed by Git.</p>
<p>This is technical debt I prefer to describe honestly:</p>
<pre><code class="language-text">latest tag
  ├── easier unattended updates
  └── weaker reproducibility and rollback</code></pre>
<p>Before a meaningful upgrade, the safe order is snapshot first, change second, validate third. If the new image changes persistent state, rolling the Deployment back may not be enough; the matching data snapshot may also be required.</p>
<p>Moving to a pinned version would make Git history a more complete rollback record. Until then, I record the running image digest during incident work rather than assuming the tag tells the whole story.</p>
<h2>Things worth remembering</h2>
<p>Actual Budget is a useful reminder that criticality has two axes.</p>
<p>It has a small blast radius: when it stops, the cluster continues normally. It has valuable state: when its data disappears, replacing the pod is the easy part.</p>
<p>At the time of writing, the shape is:</p>
<pre><code class="language-text">Workload       single Deployment
Namespace      actualbudget
Ingress        Traefik, with optional Authelia
Storage        2 GiB local-path PVC
Deployment     Kustomize + Rancher Fleet
Image policy   latest tag
Recovery       restore data, reconcile, validate in the UI</code></pre>
<p>It is deliberately a small Kubernetes application. The operational lesson is equally small: back up what cannot be rebuilt, and test the part the user actually cares about.</p>
]]></content:encoded>
					
		
		
			</item>
		<item>
		<title>Argo CD on Kubernetes: The Other GitOps Control Plane</title>
		<link>https://mutana.xyz/argo-cd-on-kubernetes-the-other-gitops-control-plane/</link>
		
		<dc:creator><![CDATA[]]></dc:creator>
		<pubDate>Fri, 28 Aug 2026 15:08:24 +0000</pubDate>
				<category><![CDATA[Notes]]></category>
		<guid isPermaLink="false">https://mutana.xyz/?p=79</guid>

					<description><![CDATA[How Argo CD remains useful beside Fleet as an alternate reconciler, migration reference, and recovery path.]]></description>
										<content:encoded><![CDATA[<p>Argo CD is not the default GitOps engine for every part of my homelab anymore. It is still important.</p>
<p>The repository has moved toward a Fleet-first model, but Argo CD remains an alternate control plane, the owner of a few legacy reconciliation paths, and a very useful record of how the platform evolved. Removing it simply because another controller became the default would erase working behaviour before that behaviour had been migrated.</p>
<pre><code class="language-text">Git repository
      │
      ▼
Argo CD repo server
      │
      ▼
Application controller
      │
      ├──► Applications
      └──► ApplicationSets
               │
               ▼
       target clusters and namespaces</code></pre>
<p>The interesting part of this setup is not installing Argo CD. It is operating two generations of GitOps without pretending they are one system.</p>
<h2>Why keep a second reconciler?</h2>
<p>Platform migrations rarely happen in one clean cut. Some workloads still depend on Argo CD Application resources. Some cluster relationships are captured in ApplicationSets. Fleet now owns the preferred deployment pattern, but the old path cannot be called obsolete while it is still reconciling real state.</p>
<p>I give the two controllers an explicit boundary:</p>
<pre><code class="language-text">Fleet
  └── preferred current GitOps path

Argo CD
  ├── legacy applications not yet migrated
  ├── alternate operator visibility
  └── migration and recovery reference</code></pre>
<p>The dangerous version of two reconcilers is allowing both to own the same resource. The useful version is knowing exactly which controller owns which path.</p>
<h2>Deployment layout</h2>
<p>Argo CD lives in the <code>argocd</code> namespace and is assembled with Kustomize:</p>
<pre><code class="language-text">argocd/
├── base/
├── shared/
├── prod/
└── oci/</code></pre>
<p>The base establishes the controller stack. Shared resources describe projects and cluster access. The production and OCI paths add environment-specific applications, ApplicationSets and ingress.</p>
<p>The current manifests track the Argo CD <code>3.3.x</code> family. The exact control plane includes the API server, repository server, application controller and supporting state such as Redis.</p>
<p>The repository is still the desired-state source of truth, but controller secrets are part of the recovery set. Git can recreate an Application definition; it cannot recreate a private repository credential or a target-cluster credential that was never stored in Git.</p>
<h2>Applications versus ApplicationSets</h2>
<p>An Application is the direct mapping:</p>
<pre><code class="language-text">one source
→ one revision and path
→ one destination</code></pre>
<p>An ApplicationSet generates several Applications from a rule. That removes repetition, but it also adds another place where a small path change can fan out across clusters.</p>
<p>When an ApplicationSet stops generating the expected children, I check the generator and repository layout before touching the workloads themselves. A missing application may be a templating problem rather than an application failure.</p>
<h2>Operating Argo CD</h2>
<p>My first health check looks at both the controller pods and the objects they reconcile:</p>
<pre><code class="language-bash">kubectl -n argocd get pods,svc,ingressroute
kubectl -n argocd get applications,applicationsets
kubectl -n argocd logs deploy/argocd-application-controller --tail=200</code></pre>
<p>If an application remains <code>OutOfSync</code> or <code>Degraded</code>, I inspect it before pressing sync repeatedly:</p>
<pre><code class="language-bash">kubectl -n argocd describe application &lt;name&gt;
kubectl -n argocd describe applicationset &lt;name&gt;
kubectl -n argocd logs deploy/argocd-repo-server --tail=200</code></pre>
<p>The failure patterns usually fall into four groups:</p>
<pre><code class="language-text">repository unreadable
  └── credential, network or revision problem

manifest path missing
  └── repository layout changed

application generated incorrectly
  └── ApplicationSet rule or template

desired objects rejected
  └── target-cluster API, permissions or invalid manifests</code></pre>
<p>The web UI is useful, but I do not treat it as the only source of diagnostics. When the API server or ingress is down, the Kubernetes objects and controller logs still describe what Argo CD is trying to do.</p>
<h2>Secrets and recovery</h2>
<p>Argo CD needs two kinds of trust material: credentials for reading repositories and credentials for reaching target clusters. Restore order matters.</p>
<p>My disaster-recovery sequence is:</p>
<pre><code class="language-text">1. Restore the argocd namespace and control-plane resources
2. Restore repository credentials
3. Restore target-cluster access
4. Apply base, shared resources and the active overlay
5. Wait for the repository server and controller
6. Confirm Application generation and sync state</code></pre>
<p>Git provides most of the configuration, which makes the controller recoverable. The secrets reconnect that configuration to the outside world.</p>
<p>The recovery objective is not to preserve every transient status value inside Argo CD. It is to restore reconciliation. Once the controllers can read Git and reach their destinations, they can rediscover most of their useful state.</p>
<h2>Rollback and migration</h2>
<p>For a bad Argo CD configuration change, the first rollback is the previous Kustomize revision. For a broken ApplicationSet, I revert the smallest generator or path change that caused the fan-out.</p>
<p>I avoid solving a GitOps incident with unrelated live edits. A manual patch can make one resource green while leaving the controller determined to change it back.</p>
<p>Migration from Argo CD to Fleet follows a deliberately boring sequence:</p>
<pre><code class="language-text">identify current owner
→ reproduce desired state in Fleet
→ verify rendered resources
→ transfer ownership without overlap
→ observe reconciliation
→ remove the old Application</code></pre>
<p>The ownership-transfer step is the whole migration. YAML conversion is the easy part.</p>
<h2>Things worth remembering</h2>
<p>Argo CD is a good example of why “legacy” should describe an architecture, not dismiss it.</p>
<p>At the time of writing:</p>
<pre><code class="language-text">Role           alternate GitOps controller
Namespace      argocd
Targets        homelab and OCI environments
Deployment     Kustomize
Version line   3.3.x
State source   Git plus repository and cluster secrets
Recovery       restore trust, reconcile, verify ownership</code></pre>
<p>It can disappear after the last dependent workload has moved and the new path has been tested. Until then, it is production infrastructure—with all the backup, access and recovery discipline that implies.</p>
]]></content:encoded>
					
		
		
			</item>
		<item>
		<title>The ARR Stack on Kubernetes: Orchestrating the Media Pipeline</title>
		<link>https://mutana.xyz/arr-stack-on-kubernetes-orchestrating-the-media-pipeline/</link>
		
		<dc:creator><![CDATA[]]></dc:creator>
		<pubDate>Fri, 28 Aug 2026 15:07:24 +0000</pubDate>
				<category><![CDATA[Notes]]></category>
		<guid isPermaLink="false">https://mutana.xyz/?p=80</guid>

					<description><![CDATA[A tour of the many small services, shared storage, and awkward boundaries behind one quiet media experience.]]></description>
										<content:encoded><![CDATA[<p>The media stack looks like one application from the sofa. In Kubernetes it is a small distributed system.</p>
<p>A request to watch something may cross a media server, one or more catalogue managers, an indexer, a download client, a VPN boundary and several different storage areas. Each component is simple enough on its own. The operational work lives in the connections between them.</p>
<pre><code class="language-text">request
  │
  ├──► Sonarr / Radarr / Lidarr
  │          │
  │          ▼
  │       Prowlarr
  │          │
  │          ▼
  │   Transmission + WireGuard
  │          │
  ▼          ▼
Jellyfin ◄── shared media storage
  │
  └──► television</code></pre>
<p>That is why I keep the stack together in the repository. It is not one container, but it is one operational story.</p>
<h2>What is in the stack?</h2>
<p>The reusable base currently defines the main applications:</p>
<pre><code class="language-text">Jellyfin      media playback
Sonarr        series management
Radarr        film management
Lidarr        music management
Prowlarr      indexer coordination
Bazarr        subtitle management
Transmission  download client
WireGuard     network boundary for the download path
Whisparr      an additional media catalogue</code></pre>
<p>Some overlays add or alter supporting components. Prowlarr, for example, has used a Flaresolverr companion. The important design choice is that these differences remain visible in overlays rather than being copied into several complete stacks.</p>
<h2>Deployment layout</h2>
<p>The manifests use a base with development and homelab overlays:</p>
<pre><code class="language-text">arr-stack/
├── base/
│   ├── one workload per application
│   ├── services
│   └── namespace
├── overlays/
│   ├── dev/
│   └── homelab/
└── fleet.yaml</code></pre>
<p>The homelab overlay runs in the <code>media-stack</code> namespace. Kustomize patches storage, Services, selected workloads and ingress; Rancher Fleet reconciles the result.</p>
<p>This is a place where the base/overlay split pays for itself. The application graph stays recognisable while the storage and network details can change per cluster.</p>
<h2>Storage is the architecture</h2>
<p>Media workloads make Kubernetes storage abstractions feel very concrete.</p>
<p>The homelab overlay separates small application configuration claims from large media volumes. Configuration occupies gigabytes. The media libraries are expressed in terabytes. Treating them as one undifferentiated PVC would make both backup policy and recovery needlessly expensive.</p>
<p>I divide the data by replaceability:</p>
<pre><code class="language-text">application configuration
  ├── indexes, histories and preferences
  └── small, valuable, worth frequent backup

download workspace
  ├── transient and partially complete data
  └── useful, but not equally valuable

media library
  ├── large payload
  └── separate retention and recovery decision</code></pre>
<p>The mounts must also agree across applications. If Sonarr sees a file under one path and Transmission reports a different path for the same bytes, every pod can be healthy while imports quietly fail.</p>
<h2>The VPN boundary</h2>
<p>The download path is coupled to WireGuard. That is deliberate, but it creates an integration that needs its own health check.</p>
<pre><code class="language-text">Transmission process healthy
≠
download path safely usable</code></pre>
<p>After changing network configuration I validate connectivity from the intended pod context and confirm that the download client can still reach both its external peers and the internal applications that control it.</p>
<p>I do not publish the provider configuration or internal routes. Those values belong to secret and environment-specific inputs, not to the public explanation of the architecture.</p>
<h2>Operating the media stack</h2>
<p>The first diagnostic pass asks Kubernetes for the whole namespace:</p>
<pre><code class="language-bash">kubectl -n media-stack get deploy,pod,svc,pvc,ingressroute
kubectl -n media-stack get events --sort-by=.lastTimestamp</code></pre>
<p>Then I narrow the problem to one edge of the graph:</p>
<pre><code class="language-bash">kubectl -n media-stack logs deploy/jellyfin --tail=200
kubectl -n media-stack logs deploy/sonarr --tail=200
kubectl -n media-stack logs deploy/radarr --tail=200
kubectl -n media-stack logs deploy/prowlarr --tail=200</code></pre>
<p>The most common categories are more useful than memorising every pod:</p>
<pre><code class="language-text">catalogue problem
  └── Sonarr, Radarr, Lidarr or Whisparr

search problem
  └── Prowlarr or a supporting resolver

download problem
  └── Transmission, WireGuard or path mapping

playback problem
  └── Jellyfin, storage throughput or media permissions

everything Ready, workflow stuck
  └── integration credentials, URLs or inconsistent mount paths</code></pre>
<p>That last category is the recurring one. Kubernetes readiness proves that the processes are alive. It does not prove that an episode can travel from request to library to playback.</p>
<h2>Backup and recovery</h2>
<p>Backing up the entire media estate with one policy would confuse size with value.</p>
<p>My useful recovery set starts with:</p>
<pre><code class="language-text">Kustomize and Fleet configuration in Git
+
application configuration volumes
+
network and integration secret inputs
+
an explicit decision for the media library</code></pre>
<p>The restore order follows the data flow:</p>
<pre><code class="language-text">1. Restore secrets and small configuration claims
2. Make shared storage available
3. Reconcile the overlay
4. Validate the VPN and download path
5. Validate catalogue-to-downloader path mappings
6. Play a real item through Jellyfin</code></pre>
<p>The final playback test is the recovery test. A page full of green pods is only preparation for it.</p>
<h2>Versioning and upgrades</h2>
<p>Several images in the homelab overlay currently track moving tags. That keeps the stack fresh, but weakens reproducibility. With many coupled applications, simultaneous unplanned upgrades can make it difficult to identify which interface changed.</p>
<p>The safer long-term shape is pinned versions updated in small groups. Until every image is pinned, I capture running image IDs during incident work and avoid assuming that a rollback to the same YAML means a rollback to the same software.</p>
<h2>Things worth remembering</h2>
<p>At the time of writing, the stack is:</p>
<pre><code class="language-text">Namespace      media-stack
Workloads      Jellyfin plus the ARR and download services
Deployment     Kustomize + Rancher Fleet
Storage        separate config, workspace and large media volumes
Network        Traefik for selected UIs, WireGuard for download traffic
Weak point     integration boundaries and moving image tags
Recovery test  complete request-to-playback workflow</code></pre>
<p>From the user side, the ideal media platform has one button. From the operator side, earning that simplicity means making every hidden hand-off explicit.</p>
]]></content:encoded>
					
		
		
			</item>
		<item>
		<title>Authelia on Kubernetes: SSO and MFA in Front of My Homelab</title>
		<link>https://mutana.xyz/authelia-on-kubernetes-sso-and-mfa-in-front-of-my-homelab/</link>
		
		<dc:creator><![CDATA[]]></dc:creator>
		<pubDate>Fri, 28 Aug 2026 15:06:25 +0000</pubDate>
				<category><![CDATA[Notes]]></category>
		<guid isPermaLink="false">https://mutana.xyz/?p=81</guid>

					<description><![CDATA[The authentication gateway where Traefik, MFA, sessions, mail, secrets, and persistent state meet.]]></description>
										<content:encoded><![CDATA[<p>Authelia is the authentication layer I put in front of services that should not be directly accessible from the Internet.</p>
<p>The idea is simple:</p>
<pre><code class="language-text">Internet
   │
   ▼
Traefik
   │
   ├──► Authelia
   │       │
   │       └──► MariaDB
   │
   ▼
Protected application</code></pre>
<p>Traefik remains responsible for ingress and TLS. Before forwarding a request to a protected application, it asks Authelia whether that request should be allowed.</p>
<p>This gives me one authentication policy instead of implementing authentication independently in every application.</p>
<p>My deployment currently runs Authelia <code>4.39.20</code> on Kubernetes using Kustomize and Rancher Fleet.</p>
<h2>Why Authelia?</h2>
<p>A homelab eventually accumulates applications with very different authentication capabilities.</p>
<p>Some support OIDC properly. Some have basic local authentication. Some have no authentication worth trusting on the public Internet.</p>
<p>Rather than solving that problem separately in every application, I use Authelia as a shared authentication gateway.</p>
<pre><code class="language-text">user
 ↓
Traefik
 ↓
Authelia policy
 ↓
MFA if required
 ↓
application</code></pre>
<p>For Internet-facing services, my default policy is two-factor authentication. Trusted internal or VPN networks can bypass MFA for selected applications where forcing a browser authentication flow would be impractical.</p>
<p>The important part is that bypasses are explicit exceptions rather than the default.</p>
<h2>Authentication methods</h2>
<p>My current setup supports three second-factor mechanisms:</p>
<ul>
<li>TOTP as the normal MFA mechanism</li>
<li>Duo Push for phone-based approval</li>
<li>WebAuthn for compatible authenticators</li>
</ul>
<p>TOTP is intentionally the baseline. It is simple, portable and does not make the authentication platform entirely dependent on an external push provider. Duo remains a convenient additional option, while WebAuthn gives me a stronger path where supported.</p>
<h2>Deployment layout</h2>
<p>I keep a reusable base and cluster-specific overlays:</p>
<pre><code class="language-text">authelia/
├── base/
│   └── kustomization.yaml
└── overlays/
    ├── homelab/
    ├── local/
    └── jls/</code></pre>
<p>Fleet reconciles the appropriate overlay into each target cluster. The application lives in the <code>auth</code> namespace and consists primarily of an Authelia Deployment and a MariaDB StatefulSet.</p>
<p>This keeps common configuration centralised while allowing environment-specific storage, routing and resources to remain in overlays. It is much easier to reason about than three nearly identical copies of the entire deployment.</p>
<h2>Configuration and secrets</h2>
<p>Authelia needs considerably more secret material than a typical stateless application. The deployment depends on values for:</p>
<pre><code class="language-text">session encryption
storage encryption
identity validation
database access
mail delivery
MFA providers</code></pre>
<p>Those values must survive disaster recovery.</p>
<p>Losing the workload is mostly harmless. Losing encryption keys while retaining the database is considerably more interesting.</p>
<p>My recovery rule is therefore:</p>
<blockquote>
<p>Configuration, database and cryptographic secrets belong to the same recovery plan.</p>
</blockquote>
<p>Secrets currently come from Kubernetes Secret manifests managed with the deployment. Moving them to a dedicated secret-management system is a separate migration. Changing the storage mechanism and rotating authentication secrets at the same time would make rollback unnecessarily difficult.</p>
<h2>Database</h2>
<p>Authelia currently uses MariaDB. The database preserves session and service state, so it runs as a StatefulSet with persistent storage.</p>
<p>For a new deployment I would evaluate PostgreSQL first, but there is little value in migrating a working authentication database merely for architectural aesthetics.</p>
<p>A database migration deserves its own sequence:</p>
<pre><code class="language-text">backup
→ migration
→ validation
→ rollback plan</code></pre>
<p>It should not be hidden inside an Authelia upgrade.</p>
<h2>SMTP matters more than it looks</h2>
<p>Mail delivery is part of the authentication system. Password resets and identity workflows depend on it, which means authentication can appear partially functional even when SMTP is broken.</p>
<p>After changing mail configuration I test a complete workflow rather than checking only whether the Authelia pod is <code>Ready</code>.</p>
<p>A healthy pod does not necessarily mean a healthy authentication service.</p>
<h2>Operating Authelia</h2>
<p>When authentication starts behaving strangely, I inspect the complete request chain instead of immediately restarting Authelia.</p>
<p>My first commands are usually:</p>
<pre><code class="language-bash">kubectl -n auth get pods,svc,pvc,ingressroute
kubectl -n auth get deploy,statefulset
kubectl -n auth logs deploy/authelia --tail=200</code></pre>
<p>Then I validate the active configuration:</p>
<pre><code class="language-bash">kubectl -n auth exec deploy/authelia -- \
  authelia validate-config \
  --config /config/configuration.yaml</code></pre>
<p>This catches a surprising number of problems before deeper investigation becomes necessary.</p>
<h2>Check the database too</h2>
<p>Because an authentication request crosses several components, inspecting only Authelia can be misleading.</p>
<pre><code class="language-bash">kubectl -n auth describe deploy authelia
kubectl -n auth describe statefulset authelia-db
kubectl -n auth logs statefulset/authelia-db --tail=100</code></pre>
<p>The failure patterns I care about most are:</p>
<pre><code class="language-text">Authelia
   ├── database connectivity
   ├── invalid or missing secrets
   ├── mail failures
   ├── MFA enrolment problems
   └── stale sessions after secret rotation

Traefik
   └── broken forward-auth middleware

MariaDB
   └── unavailable or damaged persistent state</code></pre>
<p>Authentication redirect loops are particularly useful clues. They often indicate that each component is individually running but the integration between Traefik, Authelia and the application is wrong.</p>
<h2>Resource sizing</h2>
<p>Authelia itself is inexpensive for my workload:</p>
<pre><code class="language-text">CPU request       100m
Memory request    128 MiB
Memory limit      512 MiB</code></pre>
<p>One environment receives more headroom. The extra capacity is mainly useful during memory-intensive password hashing such as Argon2.</p>
<p>MariaDB currently requests <code>50m</code> CPU and <code>128 MiB</code> memory with a <code>512 MiB</code> memory limit.</p>
<p>I change these values in Git and let GitOps reconcile them rather than editing live resources. Actual use can be checked with:</p>
<pre><code class="language-bash">kubectl -n auth top pod</code></pre>
<h2>Backup and recovery</h2>
<p>Because Authelia sits in front of several applications, I treat it as a Tier-1 homelab service.</p>
<p>The useful backup set is not simply the PVC:</p>
<pre><code class="language-text">MariaDB data
+
Authelia secrets
+
configuration
+
Traefik integration</code></pre>
<p>My recovery order is:</p>
<pre><code class="language-text">1. Restore secrets
2. Restore database or PVC state
3. Reconcile the GitOps overlay
4. Wait for Authelia and MariaDB
5. Validate configuration
6. Test forward authentication against a real protected application</code></pre>
<p>The final step matters. Seeing <code>1/1 Running</code> is not a recovery test. Successfully authenticating through Traefik is.</p>
<h2>Rollback</h2>
<p>Application changes are deployed through Git, so the normal rollback is equally boring:</p>
<pre><code class="language-text">revert Git revision
        ↓
Fleet reconciliation
        ↓
previous known-good state</code></pre>
<p>If an upgrade modified persistent state, the corresponding database snapshot may also have to be restored.</p>
<p>Authentication policy changes need slightly different treatment. If an MFA policy unexpectedly locks users out, I revert the access-control rule rather than changing encryption secrets in an attempt to regain access.</p>
<p>Change the smallest thing necessary.</p>
<h2>Things worth remembering</h2>
<p>Authelia itself is not particularly difficult to run. The interesting part is everything around it.</p>
<p>It sits at the intersection of:</p>
<pre><code class="language-text">DNS
TLS
Traefik
sessions
database
mail
secrets
MFA
application routing</code></pre>
<p>That means an “Authelia outage” frequently is not an Authelia outage.</p>
<p>At the time of writing:</p>
<pre><code class="language-text">Authelia      4.39.20
Ingress       Traefik
Database      MariaDB
Deployment    Kustomize + Rancher Fleet
MFA default   TOTP
Additional    Duo Push + WebAuthn
Backups       database + secrets + PVC snapshot</code></pre>
<p>Authentication is one of the places where I prefer boring components, explicit recovery procedures and predictable Git history over clever infrastructure.</p>
<p>The service only becomes interesting when it disappears. Ideally, it does not.</p>
]]></content:encoded>
					
		
		
			</item>
		<item>
		<title>Baby Buddy on Kubernetes: Keeping Family Data Close</title>
		<link>https://mutana.xyz/baby-buddy-on-kubernetes-keeping-family-data-close/</link>
		
		<dc:creator><![CDATA[]]></dc:creator>
		<pubDate>Fri, 28 Aug 2026 15:05:25 +0000</pubDate>
				<category><![CDATA[Notes]]></category>
		<guid isPermaLink="false">https://mutana.xyz/?p=82</guid>

					<description><![CDATA[A small family application whose PostgreSQL database makes recovery more important than its resource footprint suggests.]]></description>
										<content:encoded><![CDATA[<p>Baby Buddy is a small application with a very personal job: it keeps a family log close at hand without sending that history to somebody else’s platform.</p>
<p>From Kubernetes’ point of view the deployment is ordinary. One application pod talks to one PostgreSQL database. Traefik provides the web route. From my point of view the data is irreplaceable in a way that CPU time is not.</p>
<pre><code class="language-text">Browser
   │
   ▼
Traefik
   │
   ▼
Baby Buddy
   │
   ▼
PostgreSQL 16
   │
   ▼
5 GiB persistent volume</code></pre>
<p>That is the whole architecture, and it is enough.</p>
<h2>Why run it here?</h2>
<p>Family tracking data is useful because it is detailed. The same detail makes it private. Self-hosting gives me control over where that history lives, when it is backed up and when it is removed.</p>
<p>The application does not need a large platform around it. It needs predictable availability, safe access and a database that can be restored.</p>
<p>The distinction I keep in mind is:</p>
<pre><code class="language-text">Baby Buddy container
  └── replaceable

PostgreSQL contents
  └── not replaceable</code></pre>
<p>Kubernetes is good at recreating the first. My backup process has to protect the second.</p>
<h2>Deployment layout</h2>
<p>The repository uses a Kustomize base and two environment overlays:</p>
<pre><code class="language-text">babybuddy/
├── base/
│   ├── application Deployment
│   ├── PostgreSQL StatefulSet
│   ├── Services
│   └── namespace
├── overlays/
│   ├── homelab/
│   └── oci/
└── fleet.yaml</code></pre>
<p>Rancher Fleet selects the appropriate overlay. Both environments use the <code>babybuddy</code> namespace, while each overlay owns its route and any cluster-specific changes.</p>
<p>The application and PostgreSQL each run as a single replica. This is not a highly available database design, and it does not pretend to be. For this workload, reliable recovery is more useful than adding distributed-database complexity.</p>
<h2>Resources and storage</h2>
<p>The application requests <code>100m</code> CPU and <code>256 MiB</code> memory, with limits of one CPU and <code>512 MiB</code> memory. PostgreSQL uses the same request and limit shape.</p>
<p>The database has a <code>5 GiB</code> persistent volume claim on local-path storage.</p>
<p>Those values tell me two useful things. First, the workload is inexpensive enough to remain resident. Second, the database is small enough that frequent logical backups are practical.</p>
<p>Small data deserves good backups too.</p>
<h2>Configuration and secrets</h2>
<p>The application needs a database connection and its own runtime settings. PostgreSQL needs its database name, user and password. These values are injected through Kubernetes configuration rather than written into this public description.</p>
<p>I treat the application secret and the database backup as a pair. A restored database without the expected connection material is not a restored service. Conversely, a perfect Secret does not help if the data volume is empty.</p>
<pre><code class="language-text">recovery set
  ├── PostgreSQL data
  ├── application and database secret inputs
  ├── Kustomize overlay
  └── ingress policy</code></pre>
<h2>Operating Baby Buddy</h2>
<p>For an outage I start with both workloads:</p>
<pre><code class="language-bash">kubectl -n babybuddy get deploy,statefulset,pod,svc,pvc,ingressroute
kubectl -n babybuddy logs deploy/babybuddy --tail=200
kubectl -n babybuddy logs statefulset/babybuddy-db --tail=200</code></pre>
<p>Resource names can vary slightly after a manifest change, so I confirm them with <code>kubectl get</code> before using logs as a script.</p>
<p>The request path produces a useful decision tree:</p>
<pre><code class="language-text">route unavailable
  └── Traefik, Service or pod readiness

application starts but errors
  └── database connectivity or migrations

database pod Pending
  └── volume binding or node storage

page loads with an empty history
  └── stop and verify the mounted database before writing new data</code></pre>
<p>The last situation is the one where patience matters. An empty interface can look healthy enough to invite new entries, which would make recovery and reconciliation more confusing.</p>
<h2>Backup and recovery</h2>
<p>For PostgreSQL I prefer a logical database backup plus an infrastructure-level volume snapshot. They answer different questions.</p>
<pre><code class="language-text">logical dump
  └── portable, inspectable database recovery

volume snapshot
  └── fast restoration of the exact filesystem state</code></pre>
<p>The restore sequence is:</p>
<pre><code class="language-text">1. Restore the database secret inputs
2. Restore the PostgreSQL data or create a clean database
3. Reconcile the base and selected overlay
4. Import the logical backup if needed
5. Wait for application migrations
6. Sign in and check recent real records</code></pre>
<p>The useful validation is not only a successful connection. I check that recent entries and expected history are present and that a new test entry can be written and read.</p>
<h2>Upgrades</h2>
<p>PostgreSQL is pinned to the <code>16</code> major line. The Baby Buddy application currently tracks a moving <code>latest</code> tag.</p>
<p>That makes the pre-upgrade database backup important. A Deployment rollback can restore an older container, but it cannot automatically reverse a schema migration already applied to PostgreSQL.</p>
<p>The safe order is:</p>
<pre><code class="language-text">backup
→ record running image
→ deploy
→ watch migrations and logs
→ validate the UI</code></pre>
<p>Pinning the application to an explicit version would improve reproducibility and remains the cleaner long-term state.</p>
<h2>Things worth remembering</h2>
<p>At the time of writing:</p>
<pre><code class="language-text">Application     Baby Buddy, one Deployment
Database        PostgreSQL 16, one StatefulSet
Namespace       babybuddy
Storage         5 GiB local-path claim
Deployment      Kustomize + Rancher Fleet
Application tag latest
Recovery test   recent history plus a real write/read</code></pre>
<p>This service is a good example of why resource size is a poor measure of importance. It consumes little. The data it carries deserves care.</p>
]]></content:encoded>
					
		
		
			</item>
		<item>
		<title>BackupPC on Kubernetes: Backing Up the Machines Outside the Cluster</title>
		<link>https://mutana.xyz/backuppc-on-kubernetes-backing-up-the-machines-outside-the-cluster/</link>
		
		<dc:creator><![CDATA[]]></dc:creator>
		<pubDate>Fri, 28 Aug 2026 15:04:25 +0000</pubDate>
				<category><![CDATA[Notes]]></category>
		<guid isPermaLink="false">https://mutana.xyz/?p=83</guid>

					<description><![CDATA[Why a backup service inside Kubernetes still has to be designed around data that lives beyond Kubernetes.]]></description>
										<content:encoded><![CDATA[<p>BackupPC is the service that reaches beyond the cluster.</p>
<p>Most Kubernetes applications are concerned with their own volumes. BackupPC is concerned with the machines around Kubernetes: workstations, servers and other systems whose useful data does not live in a pod at all.</p>
<pre><code class="language-text">machines on the network
         │
         ▼
     BackupPC
       │   │
       │   └──► configuration state
       ▼
   backup pool</code></pre>
<p>Running a backup server as a Kubernetes Deployment is convenient, but it introduces a circular question: what backs up the backup service?</p>
<h2>Why BackupPC?</h2>
<p>My estate is mixed. Some data lives in Kubernetes volumes. Some lives on ordinary Linux hosts. Some machines are not always online. BackupPC provides a central schedule and history for the latter group.</p>
<p>It is not the only layer of protection. Storage snapshots protect Kubernetes state; Git protects manifests; application-aware dumps protect databases. BackupPC covers filesystems outside those boundaries.</p>
<pre><code class="language-text">Git                desired configuration
volume snapshots   Kubernetes persistent state
database dumps     application-consistent state
BackupPC           files on machines outside the cluster</code></pre>
<p>The overlap is intentional. A single backup mechanism rarely matches every kind of data.</p>
<h2>Deployment shape</h2>
<p>The current workload is a single Deployment in the <code>prod</code> namespace using the <code>adferrand/backuppc</code> image. A Kubernetes Service exposes the web application internally, and Traefik provides the controlled browser route.</p>
<p>The manifest also defines separate persistent areas for BackupPC’s configuration and working state. The declared Kubernetes claims are small because the large backup pool follows its own storage path and capacity planning.</p>
<p>That distinction matters:</p>
<pre><code class="language-text">BackupPC configuration
  └── schedules, hosts and application state

backup pool
  └── the actual retained data, usually much larger</code></pre>
<p>Restoring only one half produces either a server with no history or a pile of backup data the server does not understand.</p>
<h2>Access to client machines</h2>
<p>A backup job crosses several trust boundaries. BackupPC needs a network path to a client, an authentication method, permission to read the intended files, and enough time to complete.</p>
<p>I keep those concerns separate when debugging:</p>
<pre><code class="language-text">can the pod resolve and reach the client?
can it authenticate?
can it read the selected paths?
can it write to the backup pool?
does the job finish inside its window?</code></pre>
<p>A failed backup is not always a BackupPC application problem. A sleeping workstation, rotated key, changed filesystem permission or full storage target can produce the same red result in the UI.</p>
<p>Credentials and client inventories are not part of this public log. They belong in the protected runtime configuration and in the recovery documentation kept with the estate.</p>
<h2>Operating BackupPC</h2>
<p>My first Kubernetes checks are:</p>
<pre><code class="language-bash">kubectl -n prod get deploy,pod,svc,pvc,ingressroute
kubectl -n prod logs deploy/backuppc --tail=200
kubectl -n prod describe deploy backuppc</code></pre>
<p>Then I check storage attachment and capacity. A Ready web interface is not useful if the pool is read-only or full.</p>
<pre><code class="language-bash">kubectl -n prod get pvc
kubectl -n prod describe pvc</code></pre>
<p>For a single failing client, I avoid restarting the whole service. I compare the last successful run, test network reachability from the pod and verify that the client-side access method still works.</p>
<p>The diagnostic split is:</p>
<pre><code class="language-text">all clients fail
  └── server, shared credential, network or backup-pool issue

one client fails
  └── client availability, key, permissions or path change

jobs run but retention is wrong
  └── scheduling or pool configuration

UI works but restores fail
  └── catalogue and retained data no longer agree</code></pre>
<p>That final category is why restore testing matters more than a row of successful job timestamps.</p>
<h2>Backup verification</h2>
<p>I judge BackupPC by restores, not backups.</p>
<p>A lightweight verification selects a small file from a recent backup, restores it to a safe temporary location and compares the content. A deeper exercise restores a directory tree with permissions and timestamps, then records how long the process took.</p>
<pre><code class="language-text">backup completed
→ file visible in catalogue
→ restore requested
→ bytes recovered
→ content and metadata checked</code></pre>
<p>This tests the catalogue, the pool, permissions and the operator path at once.</p>
<h2>Recovering BackupPC itself</h2>
<p>The useful recovery set includes:</p>
<pre><code class="language-text">Kubernetes manifests
+
BackupPC configuration and catalogue
+
protected access credentials
+
backup pool</code></pre>
<p>My recovery order is:</p>
<pre><code class="language-text">1. Make the backup storage available without modifying it
2. Restore BackupPC configuration and catalogue state
3. Restore access credentials
4. Reconcile the Deployment and route
5. Confirm old backup generations are visible
6. Restore a known file
7. Only then resume scheduled writes</code></pre>
<p>Mounting the pool read-only during the first inspection is a useful precaution. It prevents a misconfigured fresh instance from treating old data as disposable workspace.</p>
<h2>Rollback and upgrades</h2>
<p>The image is not currently pinned to an explicit version in the Deployment. That makes the running image digest part of any serious incident record.</p>
<p>Before an upgrade I preserve the catalogue and configuration, note the current digest and verify a recent restore. If the upgraded service cannot read its existing pool, reverting the YAML alone may fetch the same moving image again.</p>
<p>Pinning the application version would make this much less ambiguous.</p>
<h2>Things worth remembering</h2>
<p>At the time of writing:</p>
<pre><code class="language-text">Workload       single Deployment
Namespace      prod
Ingress        Traefik
State          configuration, catalogue and backup pool
Clients        machines outside Kubernetes
Weak point     an untested restore path
Recovery test  restore and compare a real file</code></pre>
<p>A backup system is infrastructure whose output may remain unused for months. That makes routine evidence unusually important. The most reassuring green icon is a file that came back.</p>
]]></content:encoded>
					
		
		
			</item>
		<item>
		<title>CrowdSec on Kubernetes: Turning Edge Noise into Decisions</title>
		<link>https://mutana.xyz/crowdsec-on-kubernetes-turning-edge-noise-into-decisions/</link>
		
		<dc:creator><![CDATA[]]></dc:creator>
		<pubDate>Fri, 28 Aug 2026 15:03:26 +0000</pubDate>
				<category><![CDATA[Notes]]></category>
		<guid isPermaLink="false">https://mutana.xyz/?p=84</guid>

					<description><![CDATA[The local API, shared decisions, and operational checks behind a compact collaborative security layer.]]></description>
										<content:encoded><![CDATA[<p>CrowdSec sits near the noisy edge of my homelab and turns repeated hostile behaviour into decisions that other components can use.</p>
<p>The piece I run in Kubernetes is centred on the local API. Agents and security components can report observations, the API keeps shared state, and consumers can ask whether an address should be allowed or blocked.</p>
<pre><code class="language-text">logs and security agents
          │
          ▼
     CrowdSec LAPI
       │       │
       │       └──► decision consumers
       ▼
 persistent state</code></pre>
<p>There is also a Metabase container in the current pod for exploring the data. It makes the Deployment heavier than the CrowdSec API alone, but it gives the stored decisions a human-readable view.</p>
<h2>Why centralise decisions?</h2>
<p>Each Internet-facing component can see only part of an attack. A reverse proxy sees requests. A host agent sees authentication failures. A bouncer enforces a decision but may not know how that decision was reached.</p>
<p>The local API provides a meeting point:</p>
<pre><code class="language-text">observation
→ scenario matched
→ decision stored
→ bouncer queries decision
→ traffic allowed or denied</code></pre>
<p>This is useful because enforcement remains close to the edge while detection can combine signals over time.</p>
<p>It is not a substitute for authentication, patching or firewall policy. CrowdSec is another layer, particularly good at turning repetitive background noise into something actionable.</p>
<h2>Current deployment</h2>
<p>The current Kubernetes resources are compact and somewhat legacy in layout: a persistent volume claim and a Deployment rather than a full base-and-overlay tree.</p>
<p>The Deployment runs:</p>
<pre><code class="language-text">CrowdSec       v1.4.1
Metabase       v0.44.4
init helper    prepares shared state</code></pre>
<p>The persistent claim is <code>500 MiB</code> on Longhorn storage. CrowdSec itself requests <code>50m</code> CPU and <code>50 MiB</code> memory, with limits of <code>150m</code> CPU and <code>100 MiB</code> memory. Metabase requests the same small baseline but has a much larger memory ceiling of roughly <code>1.5 GiB</code>.</p>
<p>That resource difference is a useful troubleshooting clue. If the pod is killed for memory, the analytics side may be the cause even when CrowdSec appears in the pod name.</p>
<h2>State and trust</h2>
<p>The API stores decisions, enrolment information and operational context. Losing it does not remove the underlying log sources, but it does interrupt the shared memory of the security layer.</p>
<p>Clients also need credentials to report or consume decisions. I keep those credentials out of the public manifests and out of this article.</p>
<p>The recovery set is therefore:</p>
<pre><code class="language-text">LAPI persistent state
+
agent and bouncer credentials
+
Deployment configuration
+
the enforcement integration</code></pre>
<p>Restoring the API without reconnecting a bouncer produces a healthy dashboard and no protection. Restoring a bouncer credential without the API state produces a client with nothing useful to ask.</p>
<h2>Operating CrowdSec</h2>
<p>I start with the pod, Service and persistent claim:</p>
<pre><code class="language-bash">kubectl get deploy,pod,svc,pvc -l app=crowdsec
kubectl logs deploy/crowdsec-lapi -c crowdsec --tail=200
kubectl logs deploy/crowdsec-lapi -c metabase --tail=100</code></pre>
<p>Because the two main containers share a pod, I always name the container when reading logs. Otherwise it is easy to investigate the analytics UI while the local API is the component that agents actually depend on.</p>
<p>The useful failure map is:</p>
<pre><code class="language-text">agents cannot report
  └── LAPI Service, credentials or network path

bouncers receive no decisions
  └── enrolment, API state or enforcement integration

pod restarts under memory pressure
  └── inspect each container, especially Metabase

dashboard unavailable, enforcement works
  └── analytics failure rather than security-path failure

everything healthy, attacks pass through
  └── scenario coverage or bouncer placement</code></pre>
<p>The last case is the most important. CrowdSec can be perfectly healthy as software and irrelevant as a control if the enforcement point is not actually consulting it.</p>
<h2>End-to-end validation</h2>
<p>I prefer a safe synthetic validation over waiting for hostile traffic.</p>
<p>The test should prove three separate things:</p>
<pre><code class="language-text">1. an agent can reach the local API
2. a known test decision appears in the API
3. the intended bouncer observes that decision</code></pre>
<p>I do not use a production client address for the test. Security automation is a poor place for an accidental self-lockout.</p>
<p>After validation, I remove the test decision and confirm normal access.</p>
<h2>Backup and recovery</h2>
<p>The persistent data is small enough that frequent snapshots are inexpensive. The less visible task is preserving the credential and enrolment relationships around it.</p>
<p>My recovery sequence is:</p>
<pre><code class="language-text">1. Restore the PVC state
2. Recreate the Deployment and Service
3. Restore agent and bouncer trust material
4. Confirm agents are reporting
5. Confirm consumers can query decisions
6. Run a safe end-to-end decision test</code></pre>
<p>If the API state cannot be restored, re-enrolling clients is possible, but it is a rebuild rather than a transparent recovery. I document that distinction before an incident.</p>
<h2>Upgrades and age</h2>
<p>The pinned versions in this deployment are old enough that an upgrade deserves its own planned change. Jumping several releases can affect data formats, collections, scenarios and client compatibility.</p>
<p>The safe approach is:</p>
<pre><code class="language-text">snapshot
→ read release and migration notes
→ update CrowdSec separately from Metabase
→ validate agents
→ validate bouncers
→ keep a rollback image and data snapshot</code></pre>
<p>Separating the two application upgrades keeps a CrowdSec problem from being confused with a Metabase problem.</p>
<h2>Things worth remembering</h2>
<p>At the time of writing:</p>
<pre><code class="language-text">Core            CrowdSec LAPI 1.4.1
Analytics       Metabase 0.44.4
Workload        multi-container Deployment
Storage         500 MiB Longhorn claim
Purpose         shared observations and decisions
Recovery test   agent → LAPI → bouncer</code></pre>
<p>The value of CrowdSec is not the number of decisions stored. It is whether a useful signal reaches the correct enforcement point before the next request does.</p>
]]></content:encoded>
					
		
		
			</item>
		<item>
		<title>Dashy on Kubernetes: A Front Door for the Homelab</title>
		<link>https://mutana.xyz/dashy-on-kubernetes-a-front-door-for-the-homelab/</link>
		
		<dc:creator><![CDATA[]]></dc:creator>
		<pubDate>Fri, 28 Aug 2026 15:02:26 +0000</pubDate>
				<category><![CDATA[Notes]]></category>
		<guid isPermaLink="false">https://mutana.xyz/?p=85</guid>

					<description><![CDATA[A dashboard is easy to deploy; keeping its links, state, and failure mode boring is the useful part.]]></description>
										<content:encoded><![CDATA[<p>Dashy is the front door to my homelab, but it is not the foundation.</p>
<p>That distinction is deliberate. The dashboard collects links and gives the growing application estate a readable shape. If Dashy is unavailable, the services behind it should still work. A broken map is inconvenient; it should not close the roads.</p>
<pre><code class="language-text">Browser
   │
   ▼
Traefik
   │
   ▼
Dashy
   │
   ├──► platform tools
   ├──► security tools
   ├──► data applications
   └──► media applications</code></pre>
<p>That makes Dashy operationally simple and editorially important. It is where a collection of workloads starts to feel like one homelab.</p>
<h2>Why a dashboard?</h2>
<p>Bookmarks work until they do not. As services move, multiply or gain different access policies, a curated launch page becomes easier to maintain than browser state scattered across devices.</p>
<p>Dashy gives me one place to express:</p>
<pre><code class="language-text">what the service is called
what it is for
where its entry point lives
which group it belongs to</code></pre>
<p>The dashboard is also a quiet inventory check. A dead link often reveals an ingress change, a retired application or an undocumented rename.</p>
<p>I avoid turning it into a control plane. Dashy does not own the service, the route or the authentication policy. It points to them.</p>
<h2>Current deployment</h2>
<p>The Kubernetes shape is compact:</p>
<pre><code class="language-text">single Deployment
    │
    ├── Dashy container
    ├── volume-permissions init container
    └── 500 MiB persistent claim

Service
    │
    ▼
Traefik route</code></pre>
<p>Dashy runs as one replica. The main container mounts <code>/app/public</code> from a <code>500 MiB</code> Longhorn-backed claim. A BusyBox init container fixes ownership on that volume before Dashy starts.</p>
<p>The pod runs with user and group ID <code>1000</code> and the <code>Europe/Paris</code> timezone. Those are small details, but mismatched permissions are a common reason a dashboard can read its initial state and then fail when it tries to update it.</p>
<p>The current Dashy image is not pinned and uses <code>Always</code> pull policy. The permissions helper is pinned to BusyBox <code>1.31.1</code>.</p>
<h2>Configuration is content</h2>
<p>For this service the persistent files are not a database in the usual sense. They are the dashboard’s content and presentation state.</p>
<p>I treat that material as curated configuration:</p>
<pre><code class="language-text">links
groups
labels
icons
layout</code></pre>
<p>A restore that brings the pod back but loses the catalogue produces a technically healthy blank dashboard. That is why the volume contents belong in backup, even though the application itself is disposable.</p>
<p>There is also a privacy boundary. The public story can name the products in the lab. The private dashboard configuration can contain routes and labels that should not be published. Backing it up does not mean copying it into Git without review.</p>
<h2>Authentication and failure boundaries</h2>
<p>Traefik owns the HTTPS route, and the authentication layer can protect the page before Dashy receives the request.</p>
<p>I test those concerns separately:</p>
<pre><code class="language-text">route and TLS
→ authentication flow
→ Dashy page
→ one representative destination link</code></pre>
<p>A redirect loop is usually an ingress or authentication integration problem. A page with broken cards is usually dashboard content. A working card that opens a dead service is a downstream application problem.</p>
<p>Keeping those boundaries visible prevents a harmless dashboard issue from becoming an unnecessary restart of half the cluster.</p>
<h2>Operating Dashy</h2>
<p>The first Kubernetes checks are:</p>
<pre><code class="language-bash">kubectl get deploy,pod,svc,pvc,ingressroute -l app=dashy
kubectl logs deploy/dashy -c dashy --tail=200
kubectl logs deploy/dashy -c volume-permissions --tail=100</code></pre>
<p>If the pod is stuck in init, I inspect the claim and file ownership. If the pod is Ready but the page is unavailable, I inspect the Service and Traefik route. If the page loads with missing content, I stop before saving changes and verify which volume is mounted.</p>
<pre><code class="language-text">Init:Error
  └── volume attachment or ownership

CrashLoopBackOff
  └── application startup or incompatible persisted content

Ready, no page
  └── Service or ingress

Ready, empty page
  └── wrong, empty or unreadable persistent state</code></pre>
<p>For a dashboard, an end-to-end health check opens the page and follows at least one link from each major group. That catches stale content which pod probes cannot see.</p>
<h2>Backup and recovery</h2>
<p>The useful backup set is small:</p>
<pre><code class="language-text">Dashy persistent content
+
runtime access policy
+
Kubernetes manifests</code></pre>
<p>Git or the manifest repository can recreate the Deployment, Service and route. A snapshot or file backup must preserve the curated content.</p>
<p>My recovery order is:</p>
<pre><code class="language-text">1. Restore the persistent content
2. Recreate the PVC if necessary
3. Apply the workload and route
4. Confirm the init container completes
5. Open Dashy through the normal authentication path
6. Follow representative links</code></pre>
<p>The final link test is important. Restoring a year-old dashboard perfectly can still produce a page full of obsolete routes.</p>
<h2>Upgrades</h2>
<p>The unpinned image and <code>Always</code> pull policy mean a pod restart can also be an application upgrade. That is convenient until it becomes surprising.</p>
<p>Before restarting during an incident, I record the current image ID:</p>
<pre><code class="language-bash">kubectl get pod -l app=dashy \
  -o jsonpath=&#x27;{range .items[*].status.containerStatuses[*]}{.imageID}{&quot;\n&quot;}{end}&#x27;</code></pre>
<p>The safer future state is an explicit Dashy version updated through Git. That turns a restart back into a restart and makes rollback predictable.</p>
<h2>Things worth remembering</h2>
<p>At the time of writing:</p>
<pre><code class="language-text">Workload       one Deployment
Storage        500 MiB Longhorn claim
Init           BusyBox permissions helper
Ingress        Traefik with shared access control
Image policy   unpinned, always pull
Recovery test  page plus representative destination links</code></pre>
<p>Dashy’s best feature is not that it makes the homelab look organised. It is that the organisation remains useful when the underlying estate changes.</p>
]]></content:encoded>
					
		
		
			</item>
		<item>
		<title>DefectDojo on Kubernetes: One Inbox for Homelab Vulnerabilities</title>
		<link>https://mutana.xyz/defectdojo-on-kubernetes-one-inbox-for-homelab-vulnerabilities/</link>
		
		<dc:creator><![CDATA[]]></dc:creator>
		<pubDate>Fri, 28 Aug 2026 15:01:27 +0000</pubDate>
				<category><![CDATA[Notes]]></category>
		<guid isPermaLink="false">https://mutana.xyz/?p=86</guid>

					<description><![CDATA[How scan results become an operational workflow through Django, workers, PostgreSQL, Valkey, and a carefully split ingress.]]></description>
										<content:encoded><![CDATA[<p>DefectDojo is where security findings stop being files produced by scanners and start becoming work.</p>
<p>Trivy, Gitleaks, Kubeconform and Nessus can all produce useful results. Without a common intake, those results remain scattered across CI logs and exported reports. DefectDojo gives them a shared product, engagement and test history so repeated scans can update findings instead of creating another pile of disconnected output.</p>
<pre><code class="language-text">CI scanners and scheduled scans
            │
            ▼
       DefectDojo API
            │
            ▼
Traefik → nginx → Django / uWSGI
                    │
                    ├──► PostgreSQL
                    ├──► Valkey
                    └──► Celery worker and beat</code></pre>
<p>This is one of the more substantial application stacks in my homelab. The complexity is justified by the workflow it creates, but it deserves an equally explicit recovery plan.</p>
<h2>One inbox for findings</h2>
<p>My import automation uses the v2 API and re-import semantics. The first upload establishes the scan context; later pipeline runs update the same Test rather than duplicating every finding.</p>
<p>The current sources include:</p>
<pre><code class="language-text">Trivy       filesystem and dependency findings
Gitleaks    secret-scanning findings
Kubeconform Kubernetes schema findings
Nessus      scheduled infrastructure findings</code></pre>
<p>The important output is not a larger finding count. It is a shorter path from observation to triage, remediation and evidence that the issue stayed fixed.</p>
<h2>Deployment layout</h2>
<p>DefectDojo uses a Fleet-native Helm release with a Helm-free Kustomize post-render layer. A separate Helm-enabled Kustomize entry point remains available for local rendering.</p>
<pre><code class="language-text">defectdojo/
├── base/
├── overlays/
│   ├── homelab/
│   │   ├── values
│   │   └── fleet post-render resources
│   └── local/
│       ├── values
│       └── fleet post-render resources
└── fleet.yaml</code></pre>
<p>The homelab environment currently uses chart <code>1.9.29</code>; the local, production-like environment uses chart <code>1.9.37</code>.</p>
<p>The application lives in the <code>defectdojo</code> namespace. Its runtime includes Django, nginx, Celery worker and scheduler, PostgreSQL, Valkey and an initializer Job.</p>
<h2>Why the render path is split</h2>
<p>Fleet inflates the Helm chart itself. Its post-render Kustomize directory therefore must not contain another <code>helmCharts</code> block. The manual entry point does contain that block and must be rendered with Helm support enabled.</p>
<pre><code class="language-text">Fleet path
  Helm render by Fleet
  → Helm-free Kustomize post-render

manual path
  Kustomize with Helm enabled
  → complete local render</code></pre>
<p>Mixing the two paths produces a memorable but unhelpful failure: the controller asks Kustomize to render a Helm chart without the Helm flag. Keeping the paths separate makes both uses explicit.</p>
<p>There is a second Helm wrinkle around the PostgreSQL password. During an upgrade, the subchart tries to recover an existing value with <code>lookup()</code>. Fleet renders in a dry-run context where that lookup cannot see the downstream Secret. I provide the same value through Fleet’s protected values input at render time while the pod still reads its runtime credential from the namespace Secret.</p>
<p>The lesson is broader than this chart: render-time configuration and runtime configuration can have different visibility, even when they represent the same credential.</p>
<h2>Node placement and storage</h2>
<p>The local cluster spans several cloud providers. I pin all DefectDojo components to one labelled node so Django, workers, PostgreSQL, Valkey and local-path storage remain together.</p>
<p>The chart does not honour one global node selector, so the overlay applies placement to every component. If the label disappears, the failure appears as a collection of Pending pods and unbound volumes.</p>
<p>Persistent state includes:</p>
<pre><code class="language-text">PostgreSQL   findings, users and workflow state
Valkey       queue and cache support
media PVC    uploaded reports and attachments</code></pre>
<p>The media claim is <code>20 GiB</code>. PostgreSQL and Valkey also have their own persistent claims. Database backup and media snapshot belong to the same recovery conversation.</p>
<h2>Two authentication paths</h2>
<p>The browser UI and automation need different ingress behaviour.</p>
<p>The UI passes through Authelia. The API route bypasses the browser-oriented forward-auth middleware and relies on DefectDojo API tokens. Traefik gives the API path a higher-priority match so CI receives JSON from DefectDojo instead of an HTML login page.</p>
<pre><code class="language-text">browser
  → Authelia
  → DefectDojo UI

CI importer
  → token-authenticated API route
  → DefectDojo API</code></pre>
<p>Bypass does not mean unauthenticated. It means the API uses the authentication mechanism designed for automation.</p>
<h2>Operating DefectDojo</h2>
<p>I begin with the initializer and the main Django containers:</p>
<pre><code class="language-bash">kubectl -n defectdojo get pods,job,svc,pvc,ingressroute
kubectl -n defectdojo logs job/defectdojo-initializer --tail=200
kubectl -n defectdojo logs deploy/defectdojo-django -c uwsgi --tail=200
kubectl -n defectdojo logs deploy/defectdojo-django -c nginx --tail=200</code></pre>
<p>Then I inspect the background and stateful components:</p>
<pre><code class="language-bash">kubectl -n defectdojo logs deploy/defectdojo-celery-worker --tail=100
kubectl -n defectdojo logs statefulset/defectdojo-postgresql --tail=100
kubectl -n defectdojo logs statefulset/defectdojo-valkey --tail=100</code></pre>
<p>The first diagnostic split is:</p>
<pre><code class="language-text">first boot fails
  └── initializer, bootstrap inputs or database readiness

login or page requests restart
  └── uWSGI memory pressure or application settings

imports queue but do not finish
  └── Celery, Valkey or worker resources

CI receives a login page
  └── API route lost priority to Authelia-protected UI route

all pods Pending on local cluster
  └── dedicated node label or local volume binding</code></pre>
<p>A platform with several processes needs several health checks. The Django page can load while background imports are stuck.</p>
<h2>Backup and recovery</h2>
<p>The useful backup set is:</p>
<pre><code class="language-text">PostgreSQL backup
+
media volume snapshot
+
runtime cryptographic and database inputs
+
Fleet render inputs
+
Git configuration</code></pre>
<p>My recovery order is:</p>
<pre><code class="language-text">1. Restore placement prerequisites on the target cluster
2. Restore runtime and Fleet render inputs
3. Restore PostgreSQL and media state
4. Reconcile the correct environment
5. Confirm the initializer and all pods
6. Sign in through the UI
7. Submit or re-import a small known scan</code></pre>
<p>The final import validates the API route, token, Django application, queue, database and finding workflow together.</p>
<h2>Rollback</h2>
<p>Chart changes, overlay changes and database migrations do not have identical rollback boundaries. A Git revert can restore the previous render. It cannot guarantee that a newer application has not changed persistent state.</p>
<p>For significant upgrades I keep the matching PostgreSQL backup and media snapshot until the UI and an ingestion cycle have been validated. I also change the smallest layer possible: chart version, values, post-render resources and import automation should not all move in one opaque change.</p>
<h2>Things worth remembering</h2>
<p>At the time of writing:</p>
<pre><code class="language-text">Role          central SecOps findings intake
Namespace     defectdojo
Runtime       Django, nginx, Celery, PostgreSQL, Valkey
Deployment    Fleet Helm + Kustomize post-render
Charts        1.9.29 and 1.9.37 by environment
UI auth       Authelia forward auth
API auth      DefectDojo token on a separate route
Recovery test login plus a real scan re-import</code></pre>
<p>DefectDojo earns its complexity when it turns another scanner report into a finding with an owner, history and next action. The platform is healthy when that complete loop works—not merely when Django returns a page.</p>
]]></content:encoded>
					
		
		
			</item>
		<item>
		<title>Uptime Kuma on Kubernetes: The Monitor Behind My Public Status</title>
		<link>https://mutana.xyz/uptime-kuma-on-kubernetes-the-monitor-behind-my-public-status/</link>
		
		<dc:creator><![CDATA[]]></dc:creator>
		<pubDate>Fri, 28 Aug 2026 15:00:27 +0000</pubDate>
				<category><![CDATA[Notes]]></category>
		<guid isPermaLink="false">https://mutana.xyz/?p=87</guid>

					<description><![CDATA[The small stateful monitor that checks the homelab and feeds an intentionally stale-aware public heartbeat.]]></description>
										<content:encoded><![CDATA[<p>Uptime Kuma is the small monitor that watches the rest of the homelab—and indirectly tells this site when the lab was last known to be alive.</p>
<p>The monitoring path has two layers. Uptime Kuma performs the checks and keeps their history. A separate heartbeat job reads a deliberately limited view and pushes a static status snapshot to the public site.</p>
<pre><code class="language-text">homelab services
       │
       ▼
  Uptime Kuma
       │
       ▼
heartbeat job every 15 minutes
       │
       ▼
static public status JSON
       │
       ▼
mutana.xyz status page</code></pre>
<p>The public page does not query the private monitor from a visitor’s browser. It serves the last pushed snapshot and shows when that snapshot has become stale.</p>
<p>That is a small architecture with a useful failure mode.</p>
<h2>Why push the status?</h2>
<p>Directly exposing the monitor’s API would couple the public site to the private cluster and reveal more than the site needs.</p>
<p>The pushed heartbeat creates a narrow contract:</p>
<pre><code class="language-text">service name
category
coarse state
uptime summary
generation timestamp</code></pre>
<p>The public site can render that data without credentials or a live connection back into the homelab.</p>
<p>If the cluster, the monitor or the heartbeat job fails, the last file remains available. The timestamp eventually becomes stale and the site says so. Frozen green data is not repainted as current truth.</p>
<h2>Current deployment</h2>
<p>Uptime Kuma runs as a single StatefulSet in the <code>monitoring</code> namespace:</p>
<pre><code class="language-text">Traefik
   │
   ▼
Service on port 3001
   │
   ▼
Uptime Kuma 1.18.5
   │
   ▼
1 GiB persistent claim</code></pre>
<p>The homelab volume uses Longhorn storage and mounts at <code>/app/data</code>. A development variant uses a different storage class but keeps the same workload shape.</p>
<p>The StatefulSet has one replica. This means the monitor has stable storage, not high availability. For a homelab monitor, a reliable history backup and an honest stale state are more valuable than pretending one in-cluster replica can observe every cluster-wide failure.</p>
<h2>The observer problem</h2>
<p>A monitor inside the platform it watches has a blind spot. If the whole cluster disappears, Uptime Kuma disappears with it.</p>
<p>The pushed status file softens that problem but does not eliminate it:</p>
<pre><code class="language-text">cluster healthy
  → current heartbeat arrives

cluster unhealthy
  → heartbeat stops
  → public site keeps last snapshot
  → snapshot ages into stale state</code></pre>
<p>This is not equivalent to an independent external probe. It is an honest description of what the current architecture knows.</p>
<p>For services where independent outside-in availability matters, an external check would be the next layer. The current public status answers a narrower question: when did the homelab last report its own state?</p>
<h2>Monitoring configuration is state</h2>
<p>The <code>/app/data</code> volume contains more than a decorative history graph. It holds monitor definitions, notification relationships and application state.</p>
<p>If that volume is replaced with an empty claim, Kubernetes can show a healthy Uptime Kuma pod with zero useful monitors.</p>
<pre><code class="language-text">process health
  └── is Uptime Kuma running?

monitoring health
  └── are the expected checks running and producing recent results?</code></pre>
<p>Both questions matter after a restore.</p>
<h2>Operating Uptime Kuma</h2>
<p>I start with the StatefulSet, route and volume:</p>
<pre><code class="language-bash">kubectl -n monitoring get statefulset,pod,svc,pvc,ingressroute
kubectl -n monitoring logs statefulset/uptime-kuma --tail=200
kubectl -n monitoring describe statefulset uptime-kuma</code></pre>
<p>Then I inspect the public heartbeat separately. If Uptime Kuma is healthy but the public timestamp is old, the failure is likely in the export or upload path rather than in monitoring itself.</p>
<p>The useful split is:</p>
<pre><code class="language-text">pod Pending
  └── Longhorn claim or scheduling

pod Ready, UI unavailable
  └── Service, Traefik or access control

UI healthy, monitors missing
  └── wrong or empty data volume

monitors current, public status stale
  └── heartbeat job, credential or upload path

many monitors fail together
  └── shared DNS, ingress, network or cluster dependency</code></pre>
<p>A burst of simultaneous red checks often says more about the monitoring vantage point than about every application failing independently.</p>
<h2>End-to-end validation</h2>
<p>After a monitoring change, I validate the whole publication chain:</p>
<pre><code class="language-text">representative service responds
→ Uptime Kuma records the new check
→ heartbeat job exports a fresh snapshot
→ public timestamp advances
→ public state matches the private coarse state</code></pre>
<p>I also test staleness in the site fixture. A status page that only looks correct while data is fresh has not implemented its most important failure state.</p>
<h2>Backup and recovery</h2>
<p>The useful recovery set is:</p>
<pre><code class="language-text">Uptime Kuma data volume
+
heartbeat export configuration
+
protected upload credentials
+
Kubernetes manifests
+
public stale-state behaviour</code></pre>
<p>My recovery sequence is:</p>
<pre><code class="language-text">1. Restore the 1 GiB application data
2. Reconcile the StatefulSet, Service and route
3. Confirm the expected monitors and recent history
4. Restore the heartbeat job inputs
5. run a dry export
6. publish one fresh heartbeat
7. verify the public timestamp and service rows</code></pre>
<p>The public status snapshot is replaceable. The monitor catalogue and its history are the state worth preserving.</p>
<h2>Versioning</h2>
<p>The deployment currently pins Uptime Kuma <code>1.18.5</code>. That makes rollbacks reproducible, but the version also deserves a planned upgrade path rather than permanent stability by neglect.</p>
<p>Before changing it I snapshot <code>/app/data</code>, read the intervening migration notes, update one environment first and confirm both the UI and heartbeat contract. The public JSON should not have to change merely because the monitor’s internal schema changes.</p>
<p>Keeping that contract small is what decouples the site from the monitoring product.</p>
<h2>Things worth remembering</h2>
<p>At the time of writing:</p>
<pre><code class="language-text">Uptime Kuma    1.18.5
Workload       one StatefulSet
Namespace      monitoring
Storage        1 GiB Longhorn claim
Ingress        Traefik
Public status  pushed static snapshot every 15 minutes
Failure mode   explicit stale state
Recovery test  monitor → heartbeat → public page</code></pre>
<p>Monitoring is not only the act of detecting failure. It is also the discipline of saying what the monitoring system cannot currently know.</p>
]]></content:encoded>
					
		
		
			</item>
	</channel>
</rss>
