When a Formatting Command Becomes an Outage
On March 18, 2025, what started as a routine infrastructure housekeeping task turned into a full-blown outage. The trigger wasn't a bad deploy or a misconfigured network policy — it was a YAML reformatting command run against a k3s cluster's manifest directory.
The Setup: A Self-Hosted Everything Stack
The cluster in question is a small k3s installation running on a dedicated Hetzner server in Germany, with additional nodes spread across Hetzner's global regions. It hosts a custom CDN for a personal website, a Forgejo instance, and a collection of internal services. The infra repository lives on GitHub — deliberately not on Forgejo — to avoid a circular dependency where the deployment tool depends on the platform it deploys.
Deployment follows a straightforward pattern: a script uses rsync to push YAML manifests from the infra repository to a directory on the k3s server. The k3s reconciler watches that directory and applies changes automatically. There's no dry-run mode, no plan-then-apply step like you'd get with OpenTofu or Ansible — the reconciler compares the full set of .yaml files against existing Kubernetes resources on startup and performs whatever create, update, or delete operations are needed.
This workflow has known limitations. The reconciler doesn't block until an apply completes successfully, so errors surface only in the logs. To force a clean recreation of a resource, the operator has to comment it out, wait, then uncomment it. Checking logs requires SSH-ing into the server. Over time, the operator learned to predict the reconciler's behavior well enough to stop checking logs systematically, only connecting when things didn't become healthy within 30 seconds.
The Incident: A Four-Space Indentation
The editor settings had recently changed to indent all files with four spaces instead of two. Opening existing manifests revealed they were still using two-space indentation, so the operator asked the LLM assistant in the Zed editor — Claude 3.5 Sonnet in "Fast Edit" mode — for a command to reformat all of them at once.
The assistant produced a yq command. The operator installed yq — described as "jq but for YAML" — and reviewed the command superficially. It looked reasonable, and since the work was happening in a git repository, a revert seemed possible if something went wrong.
The command ran. git status showed only one file had been modified, which was disappointing. Rather than adapt the command to loop over files, the operator committed that single change, remembered to run the deploy script, and moved on to opening each manifest in the editor to format them individually.
Meanwhile, k3s logs on a second screen started scrolling furiously. The reconciler was trying — and failing — to create duplicates of resources that already existed. One file in particular, 300-cert-manager-chart.yaml, kept appearing in the logs. It was 1.75 MB. That was the first clue something was deeply wrong.
Immediate Lessons
This isn't a blameless postmortem. Several contributing factors stand out:
- The
yqman page was never consulted before running the command. - Only
git statuswas checked, notgit diff— a quick diff would have exposed the corruption immediately. - The operator was under significant stress that day, had insufficient sleep, and arguably should not have been working on infrastructure at all.
The incident illustrates how a seemingly benign tool — an LLM-suggested one-liner — can escalate into a critical infrastructure failure when validation habits slip. The k3s reconciler's lack of a dry-run mode meant the corrupted 1.75 MB manifest was applied without any intermediate checkpoint, and the only signal was frantic log output.
The broader lesson: automation that skips verification steps, combined with deployment tooling that offers no plan preview, creates a dangerous gap between intention and execution — one that formatting preferences can slip through.
A Misleadingly Simple yq Command
One command that looked perfectly reasonable turned out to be a trap. I ran:
yq -i -P '.' manifests/**/*.yaml
It didn't do what I expected at all. Instead of pretty-printing every YAML file under manifests/, this invocation merges all the matched files into the first file listed by the glob expansion. The remaining files go untouched, and the first one gets overwritten with a combined document. It's destructive and silent if you aren't paying attention.
Rather than just reading the documentation, I decided to run an experiment: ask several large language models what this command does and see if any of them flag the danger on their own.
A Field Test of LLM YAML Knowledge
GPT-4o
The default non-reasoning OpenAI model didn't see a problem. When asked to explain the command, it produced a perfectly confident breakdown: yq is a YAML processor like jq for JSON, -i edits in place, -P pretty-prints, '.' selects the entire document, and the glob targets all YAML files recursively. Its conclusion: this reformats everything neatly with consistent indentation. No warning, no caveat.
When I pointed out the merging behavior explicitly, it acknowledged the issue and explained that -i writes back to a single file: the first one in the glob. Then it offered a fix. The recommended solution was the exact same command, still carrying the same flaw. Calling that out led to an apology and, finally, two working alternatives: a find plus xargs pipeline, or a simple for loop.
Claude 3.7 Sonnet
Claude's first response was another polite, technically proper-looking breakdown of the flags and the glob pattern. It reached the same reassuring conclusion about consistent formatting across files. After the correction, Claude shifted tone and explained that the shell expands the glob, yq merges everything into the first file due to -i, and the rest remain unchanged. It called the behavior "very destructive if not intended."
Its suggested fix was cleaner than GPT-4o's — a for loop that processes each file individually. No need for xargs or managing the input field separator. Claude's acknowledgment felt more accurate: it understood the risk and didn't waste time offering the same broken command again.
DeepSeek R1
DeepSeek began with the same exhaustive, confident walkthrough of every flag and piece of syntax. It concluded that the command cleans up and standardizes formatting across files. Still oblivious to the problem.
After correction, it went on at length — far longer than necessary — restating the mechanics: the glob matches files, yq merges them by default, -i writes to the first file, and the others stay unchanged. It even created a dedicated heading to answer whether it knew about this behavior. Yes, it claimed awareness, just missed it in the initial explanation. Then it noted that merging might be useful for consolidating Kubernetes manifests.
Mistral's Le Chat
Le Chat replied with the standard explanation and even cited sources: the yq repository, the documentation front page, the troubleshooting section, and a 2021 blog post on YAML processing. The explanation was authoritative in tone and completely wrong in the same way as the others.
After the correction, Le Chat acknowledged the merging effect, restated the mechanics concisely, and mentioned the same potential use case for consolidation. Short, and to the point — no drawn-out apologies.
What This Says About the Tooling
All four models are aligned in one way: they all thank me for the correction. But none of them can apply that lesson anywhere. These models don't update from a conversation. The best hope is that the next generation's training data includes enough corrected examples like this one to learn where the edge cases live.
The underlying issue isn't new. Tools like yq have quirks that documentation bury in footnotes or troubleshooting pages. This kind of behavior is easy to discover the hard way, and apparently just as easy for even the most well-read LLM to gloss over. The practical takeaway is simple: when a glob expands to multiple files and you pass them to a tool with an in-place flag, know what that tool does with more than one input. The models may not warn you in time — but the version control diff will.
Rebuilding Day
So the cluster is wiped and I'm starting fresh. After rescuing /var/lib/rancher/k3s with rsync — which took a while thanks to 600K small files under agent/containerd and the fact that rsync is single-threaded with default compression that just slows things down on a fast link — I switched to a quicker transfer method for the 30GB payload. Being at home on 2.5Gbps made this far less painful than it could have been.
With backups confirmed in S3, I booted into Hetzner's installimage to reinstall Debian 12. One gotcha: Ghostty's TERM value of xterm-ghostty isn't recognized by the ncurses installer, which dumps you into nano instead. Setting TERM=xterm-256color fixes that. I briefly considered RHEL derivatives but decided today had enough excitement already — the applications are all containerized anyway, so the host OS matters less.
The New Cluster
I skipped exporting an etcd snapshot to S3 for restoration — this was a fresh start, not a restore. I put the edge nodes to sleep with a tellingly-named Ansible playbook and got going.
For k3s itself, the leader setup is straightforward. My Ansible roles are adapted from k3s-ansible, and the inventory is generated from OpenTofu state by a Rust script using the unstable -Zscript cargo flag — invoked via the ./ansible-playbook wrapper that sets up the environment.
A notable headache: the new k3s leader generates its own certificate authority, so nodes kept failing with CA hash mismatches. The CA hash for node authentication lives in /var/lib/rancher/k3s/server/node-token, which has the format:
K10<token-ca-hash>::server:<random-token>
I'd committed the old token value to an Ansible variable, encrypted with git-crypt, but the leader role also wrote that stale file over the fresh one. The fix was replacing the portion between K10 and :: in the node-token with the value from the failed assertion. GPT-4o explained this structure to me after I'd already figured it out the hard way by comparing hashes.
Restoring Core Services
Bringing back services started with the most essential one — Traefik. k3s ships Traefik v2 by default, but I want v3 for non-experimental HTTP/3, so I disable the bundled version and apply a manifest for v3 directly.
Next came cert-manager for Let's Encrypt certificates, Minio for object storage on the dedicated server's 2x512GB SSD, and k8up for restic-based backups — each deployed as a simple HelmChart resource.
For cross-namespace secret sharing, there's reflector. I keep services neatly separated into namespaces so I can make a mess in one and delete it wholesale — but you can't read secrets from another namespace. Reflector annotations on secrets specify which namespaces get mirrored copies.
CloudNativePG handles Postgres. Of all the controllers I've tried, it gets in my way the least, which matters a lot for stateful workloads. Its Helm chart pins the controller to the dedicated node.
Restoring Forgejo’s data layer
Forgejo is particular about its storage: it needs object storage (which lives in actual Amazon S3 for persistence) and local storage for git repositories and related data. Both had to be restored independently.
Postgres first
The database restore was routine — spin up a CloudNativePG restore job and wait for it to finish. You can watch the restore jobs with :jobs in k9s and tail their logs from there; once the job completes, the data is back.
That said, reading the CloudNativePG 1.25 documentation was the most painful part of the process. MkDocs is better than nothing, but that bar is low enough to trip over.
---
kind : Cluster
apiVersion : postgresql.cnpg.io/v1
metadata :
name : forgejo-db
namespace : forgejo
labels :
cnpg.io/reload : "true"
spec :
instances : 1
imageName : ghcr.io/cloudnative-pg/postgresql:16 # Specify PostgreSQL 16 image
primaryUpdateStrategy : unsupervised
affinity :
nodeSelector :
kubernetes.io/hostname : brat
storage :
size : 10Gi
pvcTemplate :
metadata :
annotations :
# do not back up the db volume with k8up (we back it up
# with barman, see below)
k8up.io/backup : "false"
nodeSelector :
kubernetes.io/hostname : brat
bootstrap :
# we're doing disaster recovery aw yiss
recovery :
source : cluster-backup
externalClusters :
- name : cluster-backup
barmanObjectStore :
destinationPath : "s3://bearcove-cnpg-backups/forgejo/brat-1/"
serverName : forgejo-db
wal :
compression : snappy
data :
compression : snappy
s3Credentials :
accessKeyId :
name : s3-credentials
key : ACCESS_KEY
secretAccessKey :
name : s3-credentials
key : SECRET_KEY
backup :
barmanObjectStore :
# pro-tip: this needs to be a different path, otherwise it's very unhappy at you.
destinationPath : "s3://bearcove-cnpg-backups/forgejo/brat-2025-03-18/"
wal :
compression : snappy
data :
compression : snappy
s3Credentials :
accessKeyId :
name : s3-credentials
key : ACCESS_KEY
secretAccessKey :
name : s3-credentials
key : SECRET_KEY
retentionPolicy : "30d"
Persistent volumes: where the trouble starts
Kubernetes treats storage like compute or memory — a fungible resource. Say how much you need, not where it lives. You can specify placement, but it’s an uphill battle.
On k3s, the local-path storage class provisions volumes as plain folders under /var/lib/rancher/k3s/storage:
---
kind : PersistentVolumeClaim
apiVersion : v1
metadata :
name : forgejo
namespace : forgejo
annotations :
k8up.io/backup : "true"
spec :
accessModes :
- ReadWriteOnce
storageClassName : local-path
resources :
requests :
storage : 20Gi
root@brat /var/lib/rancher/k3s/storage # ls -lhA
total 20K
drwxrwsrwx 3 root 1001 4.0K Mar 18 14:08 pvc-02c60622-d5c9-4dc5-8ea8-22a51eee0b83_minio_minio
drwxrwsrwx 3 root tape 4.0K Mar 28 11:33 pvc-1d42f27a-b955-49ec-9f10-42ed222602f4_umami_umami-db-pg17-1
drwxrwsrwx 3 root tape 4.0K Mar 18 14:32 pvc-2a1953cf-8867-4980-8b59-56ff2ed6411c_forgejo_forgejo-db-1
drwxrwsrwx 4 root amos 4.0K Mar 18 15:31 pvc-a79bde12-25c0-40b9-98c3-ab16a6d12afa_forgejo_forgejo
drwxrwsrwx 3 root tape 4.0K Mar 28 11:49 pvc-bfa94fbd-4105-4860-aa19-06b95d7dd573_forgejo_forgejo-db-pg17-1
Managed Kubernetes hides this complexity — volumes are provisioned by the provider’s own volume backend. On self-managed k3s running across VMs, that abstraction disappears. You can’t attach a DigitalOcean volume to a Hetzner VM. Options like Longhorn or Ceph (usually via Rook) sound attractive, but they really want three to five dedicated servers with comparable specs in the same datacenter. Local storage is often the pragmatic choice.
The catch: a persistent volume claim isn’t provisioned until a pod actually mounts it. If the first pod to touch it is a k8up restore pod running restic, and that pod lands on a small edge node instead of your dedicated server, the volume ends up in the wrong place:
That’s why the redeployment started with manifests split into files prefixed with three digits — imposing order on an orchestrator that doesn’t understand it. k3s ignores the numbering; the files were staged in old-manifests/ and copied into manifests/ manually, one at a time:
infra/manifests/forgejo on main [$]
❯ l
Permissions Size User Date Modified Name
.rw-r--r--@ 115 amos 18 Mar 18:27 000-forgejo-namespace.yaml
.rw-r--r--@ 4.8k amos 28 Mar 13:27 001-forgejo-config-secret.yaml
.rw-r--r--@ 4.2k amos 28 Mar 16:40 100-forgejo-db-cluster.yaml
.rw-r--r--@ 643 amos 28 Mar 13:27 101-forgejo-db-backups.yaml
.rw-r--r--@ 927 amos 18 Mar 18:27 200-forgejo-persistent-volumes.yaml
.rw-r--r--@ 161 amos 18 Mar 18:27 201-forgejo-backup-secrets.yaml
.rw-r--r--@ 1.4k amos 18 Mar 18:27 202-forgejo-backups.yaml
.rw-r--r--@ 1.2k amos 18 Mar 18:27 203-forgejo-backup-schedule.yaml
.rw-r--r--@ 1.5k amos 23 Mar 16:57 300-forgejo-deployment.yaml
.rw-r--r--@ 1.2k amos 23 Mar 16:57 400-forgejo-ingress.yaml
.rw-r--r-- 33 amos 18 Mar 17:14 README.md
To correct a volume on the wrong node, force provisioning where you want it by creating a dummy pod:
---
## This forces the volume to be created on node 'brat'
apiVersion : v1
kind : Pod
metadata :
name : forgejo-ls2
namespace : forgejo
spec :
restartPolicy : Never
securityContext :
runAsUser : 1000
runAsGroup : 1000
fsGroup : 1000
containers :
- name : ls-container
image : busybox
command : [ "ls" , "-lhA" , "/workdir" ]
volumeMounts :
- name : forgejo-workdir
mountPath : /workdir
nodeSelector :
kubernetes.io/hostname : brat
volumes :
- name : forgejo-workdir
persistentVolumeClaim :
claimName : forgejo
Absurd? Probably. Effective? Absolutely.
Restoring with k8up when you’re not root
The actual k8up restore had its own quirks. The Forgejo image is rootless — inside the container, processes run as user git with UID 1000:
~
❯ kubectl exec forgejo-f9dd988c4-9svkx -n forgejo -it -- /bin/bash
forgejo-f9dd988c4-9svkx:/var/lib/gitea$ whoami
git
forgejo-f9dd988c4-9svkx:/var/lib/gitea$ id
uid=1000(git) gid=1000(git) groups=1000(git)
forgejo-f9dd988c4-9svkx:/var/lib/gitea$
exit
A normal k8up restore produces files owned by root, which the git user can’t read. The fix is to specify a podSecurityContext so the restore pod also runs as UID 1000:
apiVersion : k8up.io/v1
kind : Restore
# ✂️
spec :
podSecurityContext :
runAsUser : 1000
runAsGroup : 1000
fsGroup : 1000
fsGroupChangePolicy : "OnRootMismatch"
# ✂️
That introduces a different failure: restic can’t write to its own cache directory. Restic first issues a burst of S3 GET requests to inventory backups, storing the results locally because it consults that cache constantly during the restore. If the cache is unwritable, restic still works but becomes so slow it may as well not be trying:
Setting RESTIC_CACHE_DIR to a writable path solves it — and so does giving the job generous CPU and memory limits:
---
##############################################################################
# k8up backup/restore setup
##############################################################################
apiVersion : v1
kind : ConfigMap
metadata :
name : restic-vars
namespace : forgejo
data :
RESTIC_CACHE_DIR : /tmp/restic-cache
---
apiVersion : k8up.io/v1
kind : Restore
metadata :
name : restore-workdir-2024-03-18-b
namespace : forgejo
spec :
snapshot : e9e9a75d # last `/data/forgejo` backup
podSecurityContext :
runAsUser : 1000
runAsGroup : 1000
fsGroup : 1000
fsGroupChangePolicy : "OnRootMismatch"
restoreMethod :
folder :
# restore to PVC forgejo
claimName : forgejo
backend :
repoPasswordSecretRef :
name : backup-repo
key : password
resources :
requests :
cpu : 10
memory : 1Gi
limits :
cpu : 10
memory : 4Gi
envFrom :
- configMapRef :
name : restic-vars
s3 :
bucket : bearcove-k8up-backups
endpoint : https://s3.eu-central-1.amazonaws.com
accessKeyIDSecretRef :
name : s3-credentials
key : ACCESS_KEY
secretAccessKeySecretRef :
name : s3-credentials
key : SECRET_KEY
Deployment, service, ingress
One combined manifest for Forgejo covers the essentials. The service exposes forgejo.forgejo.svc.cluster.local:80 to the namespace:
---
kind : Service
apiVersion : v1
metadata :
name : forgejo
namespace : forgejo
spec :
selector :
app : forgejo
ipFamilyPolicy : RequireDualStack
ports :
- protocol : TCP
port : 80
targetPort : http
A certificate request handles TLS for the instance’s domain:
---
apiVersion : cert-manager.io/v1
kind : Certificate
metadata :
name : tls
namespace : forgejo
spec :
secretName : tls-secret
issuerRef :
name : letsencrypt-prod
kind : ClusterIssuer
dnsNames : [ redacted.example.org ]
The ingress raises the max POST limit — needed when uploading “generic” packages to a Forgejo registry:
---
apiVersion : traefik.io/v1alpha1
kind : Middleware
metadata :
name : request-body-limit
namespace : forgejo
spec :
buffering :
maxRequestBodyBytes : 1073741824 # 1 GiB
memRequestBodyBytes : 67108864 # 64 MiB
And the ingress route ties it together:
---
apiVersion : traefik.io/v1alpha1
kind : IngressRoute
metadata :
name : forgejo
namespace : forgejo
spec :
entryPoints :
- websecure
routes :
- match : Host(`redacted.example.org`)
kind : Rule
services :
- name : forgejo
port : 80
middlewares :
- name : request-body-limit
namespace : forgejo
tls :
secretName : tls-secret
The ingress configuration is verbose and specific, even by Kubernetes standards. Labels could shorten it, but that never quite worked out. Most people use nginx for ingress; between C and Go, the lesser evil wins.
Bringing home
The website software, currently named home, also lives on k3s. It has no persistent database: assets sit in object storage, and a central “mom” service keeps one SQLite database per tenant. That database tracks which assets were uploaded, the latest revision, and sponsors fetched from GitHub and Patreon:
################################################################################
# 🐻 MOM DEPLOYMENT
################################################################################
---
kind : Deployment
apiVersion : apps/v1
metadata :
name : mom
namespace : home
labels :
group : home
app : mom
spec :
replicas : 1
selector :
matchLabels :
app : mom
template :
metadata :
labels :
group : home
app : mom
spec :
affinity :
nodeAffinity :
requiredDuringSchedulingIgnoredDuringExecution :
nodeSelectorTerms :
- matchExpressions :
- key : node-type
operator : In
values :
- dedicated
imagePullSecrets :
- name : forgejo-docker-pull-secrets
containers :
- name : mom
image : redacted.example.org/bearcove/home:32.2.4
command : [ "home" , "mom" ]
workingDir : /var/lib/home
envFrom :
- secretRef :
name : home-vars
- secretRef :
name : home-conf
env :
- name : RUST_LOG
value : "info"
- name : POD_NAME
valueFrom :
fieldRef :
fieldPath : metadata.name
- name : NODE_NAME
valueFrom :
fieldRef :
fieldPath : spec.nodeName
ports :
- containerPort : 1118
name : http
readinessProbe :
httpGet :
path : /health
port : http
initialDelaySeconds : 1
periodSeconds : 1
resources :
requests :
memory : "400Mi"
cpu : "0.5"
limits :
memory : "16000Mi"
cpu : "20"
volumeMounts :
- name : mom
mountPath : /var/lib/home
- name : metadata
mountPath : /metadata
readOnly : true
volumes :
- name : mom
persistentVolumeClaim :
claimName : mom
- name : metadata
hostPath :
path : /metadata
type : DirectoryOrCreate
Edge nodes are called “cubs”, and the deployment manifest is interesting — it runs one pod per edge node, tolerates downtime on some pods, and routes to the closest healthy node:
################################################################################
# 🧸 CUB DEPLOYMENT
################################################################################
---
kind : Deployment
apiVersion : apps/v1
metadata :
name : cub
namespace : home
labels :
group : home
app : cub
spec :
replicas : 6 # 1 dedicated + 5 edge nodes
topologySpreadConstraints :
# Define topology spread constraints for the deployment
- maxSkew : 1 # defines the maximum skew between the number of pods in different topology domains
# Use zone as the topology key
topologyKey : topology.kubernetes.io/zone
# Allow scheduling even if constraints are not met
whenUnsatisfiable : ScheduleAnyway
# Specify the label selector for the pods
labelSelector :
matchLabels :
# Match pods with the label app: cub
app : cub
affinity :
podAntiAffinity :
preferredDuringSchedulingIgnoredDuringExecution :
- weight : 100
podAffinityTerm :
labelSelector :
matchExpressions :
- key : node-type
operator : In
values :
- cloud
topologyKey : "kubernetes.io/hostname"
selector :
matchLabels :
app : cub
template :
metadata :
labels :
app : cub
group : home
spec :
imagePullSecrets :
- name : forgejo-docker-pull-secrets
containers :
- name : cub
image : redacted.example.org/bearcove/home:32.2.4
command : [ "home" , "serve" ]
envFrom :
- secretRef :
name : home-vars
- secretRef :
name : home-conf
env :
- name : POD_NAME
valueFrom :
fieldRef :
fieldPath : metadata.name
- name : NODE_NAME
valueFrom :
fieldRef :
fieldPath : spec.nodeName
ports :
- containerPort : 1111
name : http
# define readiness probe: must serve HTTP on port 1111
readinessProbe :
httpGet :
path : /
port : http
httpHeaders :
- name : x-forwarded-host
value : fasterthanli.me
initialDelaySeconds : 1
periodSeconds : 1
resources :
requests :
memory : "400Mi"
cpu : "0.5"
limits :
memory : "1200Mi"
cpu : "12"
volumeMounts :
- name : cub
mountPath : /var/lib/home
- name : metadata
mountPath : /metadata
readOnly : true
volumes :
- name : cub
persistentVolumeClaim :
claimName : cub
- name : metadata
hostPath :
path : /metadata
type : DirectoryOrCreate
Zero-downtime deploys
This setup permits zero-downtime deploys. When everything is healthy, the home namespace looks like this, with all cub pods up:
Introducing a deliberate crash exposes the safety net. First attempt — a change that breaks the build — fails at CI, never reaching the cluster:
home on main via 🦀 v1.85.1
❯ g show
commit 984d0f5b7fa5cff9172ea1bc16091b1fbc6bed6a (HEAD -> main, origin/main, origin/HEAD)
Author: Amos Wenger <[email protected]>
Date: Fri Mar 28 17:35:46 2025 +0100
crash on purpose
diff --git a/crates/home/src/main.rs b/crates/home/src/main.rs
index 119e3e91..959fa414 100644
--- a/crates/home/src/main.rs
+++ b/crates/home/src/main.rs
@@ -30,6 +30,8 @@ async fn real_main() -> eyre::Result<()> {
errhandling::load().install();
tracingsub::load().install();
+ panic!("woopsie doopsie I'm doing a demo");
+
let args = clap::load().parse();
let res = match args.sub {
The second attempt — a change that only crashes on serve in production — passes pipelines, offering a chance to demonstrate the automation:
home on main [!] via 🦀 v1.85.1
❯ gwd
crates/mod-cub/src/lib.rs --- Rust
8 #[dylo::export]
9 impl Mod for ModImpl {
10 fn serve(&self, config: Config) -> BoxFuture<'static, Result<()>> {
11 Box::pin(async {
12 if std::env::var("KUBERNETES_SERVICE_HOST").is_ok() {
13 panic!("is this a container? LET ME OUT! LEMMEOUT");
14 }
15
16 impls::serve(config)
17 .await
18 .map_err(|e| noteyre::eyre!("{}", e))
home on main via 🦀 v1.85.1
❯ bd bump
Fetched all tags from remote.
Latest tag: v32.2.4
Choose version bump type:
1. Patch (32.2.5)
2. Minor (32.3.0)
3. Major (33.0.0)
1
Creating new tag: v32.2.5
Total 0 (delta 0), reused 0 (delta 0), pack-reused 0
To https://redacted.example.org/bearcove/home
* [new tag] v32.2.5 -> v32.2.5
Tag v32.2.5 created and pushed successfully
(bd is short for beardist.) The -build jobs publish a generic package:
They also trigger a homebrew tap update (that’s the trigger-formula-update job) and push a container image:
That image isn’t rolled out automatically — the green light comes from a human, also via beardist. The k8s subcommand first finds where the given image is referenced:
infra on main [$] via 🦀 v1.85.0
❯ bd k8s bearcove/home
Searching for manifests in: manifests
YAML files containing 'bearcove/home' are:
File: manifests/home/301-home-cub-deployment.yaml
Version 32.2.4 at positions 1761 to 1808
Context:
>>> - name: cub
>>> image: redacted.example.org/bearcove/home:32.2.4
>>> command: ["home", "serve"]
>>> envFrom:
>>> - secretRef:
File: manifests/home/300-home-mom-deployment.yaml
Version 32.2.4 at positions 1074 to 1121
Context:
>>> - name: mom
>>> image: redacted.example.org/bearcove/home:32.2.4
>>> command: ["home", "mom"]
>>> workingDir: /var/lib/home
>>> envFrom:
Then it polls the Forgejo instance, waiting for a different version to appear:
Initializing Forgejo client...
Checking for new versions...
Fetching latest version for package 'home' from 'https://redacted.example.org/api/v1/packages/bearcove'
Request completed in 385ms with status 200 OK
Received 13 packages in response
Filtered to 12 matching packages
Found 11 valid versions
Latest version found: 32.2.5
New version detected: 32.2.5
When it shows up, the code updates the manifests:
Updating manifests...
Updated manifests/home/301-home-cub-deployment.yaml
Updated manifests/home/300-home-mom-deployment.yaml
Staging changes...
Showing staged changes:
manifests/home/300-home-mom-deployment.yaml --- YAML
33 imagePullSecrets:
34 - name: forgejo-docker-pull-secrets
35 containers:
36 - name: mom
37 image: redacted.example.org/bearcove/home:32.2.4
37 image: redacted.example.org/bearcove/home:32.2.5
38 command: ["home", "mom"]
39 workingDir: /var/lib/home
40 envFrom:
41 - secretRef:
manifests/home/301-home-cub-deployment.yaml --- YAML
48 imagePullSecrets:
49 - name: forgejo-docker-pull-secrets
50 containers:
51 - name: cub
52 image: redacted.example.org/bearcove/home:32.2.4
52 image: redacted.example.org/bearcove/home:32.2.5
53 command: ["home", "serve"]
54 envFrom:
55 - secretRef:
56 name: home-vars
Commits and pushes:
Committing changes...
> [email protected] lint-staged
> lint-staged
→ No staged files match any configured task.
[main d37d8eb] bump bearcove/home to 32.2.5
2 files changed, 2 insertions(+), 2 deletions(-)
Pushing changes...
Enumerating objects: 10, done.
Counting objects: 100% (10/10), done.
Delta compression using up to 8 threads
Compressing objects: 100% (6/6), done.
Writing objects: 100% (6/6), 1.17 KiB | 1.17 MiB/s, done.
Total 6 (delta 5), reused 0 (delta 0), pack-reused 0
remote: Resolving deltas: 100% (5/5), completed with 4 local objects.
To https://github.com/bearcove/infra.git
89bddc6..d37d8eb main -> main
And finally calls ./deploy-manifests, which picked up some new tricks — mostly the right rsync flags to preview changes before applying them:
Deploying manifests...
🔍 Performing dry run...
Source: ./manifests/
Destination: [email protected]:/var/lib/rancher/k3s/server/manifests/custom/
==================================================
🚨 REVIEW THIS CAREFULLY 🚨
==================================================
The following changes will be made:
==================================================
<fc.T.... home/300-home-mom-deployment.yaml
<fc.T.... home/301-home-cub-deployment.yaml
==================================================
Please review the above changes carefully before proceeding.
==================================================
🚨 Warning: This will perform the changes above.
Are you sure you want to continue? (y/n) y
🔧 Performing operations...
📁 Creating skip file: /var/lib/rancher/k3s/server/manifests/traefik.yaml.skip
🔄 Syncing files...
📜 Viewing logs...
✂️
building file list ...
60 files to consider
home/300-home-mom-deployment.yaml
2.92K 100% 2.12MB/s 0:00:00 (xfer#1, to-check=29/60)
home/301-home-cub-deployment.yaml
3.71K 100% 3.53MB/s 0:00:00 (xfer#2, to-check=28/60)
sent 4.41K bytes received 130 bytes 3.02K bytes/sec
total size is 1.07M speedup is 236.91
# in `deploy-manifests`
#!/bin/bash -euo pipefail
SRC ="./manifests/"
DST ="[email protected]:/var/lib/rancher/k3s/server/manifests/custom/"
# Define rsync flags
RSYNC_FLAGS =(--recursive --delete --checksum --human-readable --progress --include='*/' --include='*.yaml' --exclude='*' )
# Perform a dry run of rsync to show what would happen
echo "🔍 Performing dry run..."
printf "Source: \033[33m%s\033[0m\n" " $ SRC "
printf "Destination: \033[33m%s\033[0m\n" " $ DST "
printf "\033[2m==================================================\033[0m\n"
printf "\033[2m🚨 REVIEW THIS CAREFULLY 🚨\033[0m\n"
printf "\033[2m==================================================\033[0m\n"
printf "\033[2mThe following changes will be made:\033[0m\n"
printf "\033[2m==================================================\033[0m\n"
rsync " ${ RSYNC_FLAGS [@]} " --dry-run --itemize-changes " $ SRC " " $ DST "
printf "\033[2m==================================================\033[0m\n"
printf "\033[2mPlease review the above changes carefully before proceeding.\033[0m\n"
printf "\033[2m==================================================\033[0m\n"
# Ask for consent before continuing
printf "\n🚨 \033[1;31mWarning:\033[0m This will perform the changes above.\n"
read -p "Are you sure you want to continue? (y/n) " -n 1 -r
echo
if [[ ! $ REPLY =~ ^[Yy]$ ]]
then
printf "❌ \033[1;31mOperation cancelled.\033[0m\n"
exit 1
fi
# The actual operations
echo "🔧 Performing operations..."
printf "📁 Creating skip file: \033[33m/var/lib/rancher/k3s/server/manifests/traefik.yaml.skip\033[0m\n"
ssh root@brat "touch /var/lib/rancher/k3s/server/manifests/traefik.yaml.skip" &
echo "🔄 Syncing files..."
rsync " ${ RSYNC_FLAGS [@]} " " $ SRC " " $ DST " &
echo "📜 Viewing logs..."
ssh -t root@brat "journalctl -fxu k3s | ccze -A"
After the k3s reconciler wakes up, the rollout is visible:
But the site stays up the whole time:
At that point, the choice is roll back:
infra on main [$] via 🦀 v1.85.0
❯ g revert d37d8eb5fdf101dfb70427a845fee8924c058dad
[main d9602ff] Revert "bump bearcove/home to 32.2.5"
2 files changed, 2 insertions(+), 2 deletions(-)
infra on main [$⇡] via 🦀 v1.85.0
❯ gp
Enumerating objects: 10, done.
Counting objects: 100% (10/10), done.
Delta compression using up to 8 threads
Compressing objects: 100% (6/6), done.
Writing objects: 100% (6/6), 1.23 KiB | 1.23 MiB/s, done.
Total 6 (delta 5), reused 0 (delta 0), pack-reused 0
remote: Resolving deltas: 100% (5/5), completed with 4 local objects.
To https://github.com/bearcove/infra.git
35d0771..d9602ff main -> main
infra on main [$] via 🦀 v1.85.0
❯ ./deploy-manifests
🔍 Performing dry run...
Source: ./manifests/
Destination: [email protected]:/var/lib/rancher/k3s/server/manifests/custom/
==================================================
🚨 REVIEW THIS CAREFULLY 🚨
==================================================
The following changes will be made:
==================================================
<fc.T.... home/300-home-mom-deployment.yaml
<fc.T.... home/301-home-cub-deployment.yaml
==================================================
Please review the above changes carefully before proceeding.
==================================================
🚨 Warning: This will perform the changes above.
Are you sure you want to continue? (y/n) y
🔧 Performing operations...
📁 Creating skip file: /var/lib/rancher/k3s/server/manifests/traefik.yaml.skip
🔄 Syncing files...
📜 Viewing logs...
Mar 28 18:21:43bratk3s[3054412]: I0328 18:21:43.1789393054412scope.go:117] "RemoveContainer" containerID="125d2b6f93b70a5d2ff61c941c1e44128d6d847e82b7f526a8a6609caf970e11"
Mar 28 18:21:43bratk3s[3054412]: E0328 18:21:43.1791993054412pod_workers.go:1301] "Error syncing pod, skipping" err="failed to \"StartContainer\" for \"cub\" with CrashLoopBackOff: \"back-off 5m0srestartingfailed container=cub pod=cub-764bfbdfd-pmst4_home(d8884c82-0983-476a-ae97-ef6a89de2e08)\"" pod="home/cub-764bfbdfd-pmst4" podUID="d8884c82-0983-476a-ae97-ef6a89de2e08"
Mar 28 18:21:46bratk3s[3054412]: I0328 18:21:46.1886323054412range_allocator.go:247] "Successfully synced" key="brat"
Mar 28 18:21:57bratk3s[3054412]: I0328 18:21:57.1791423054412scope.go:117] "RemoveContainer" containerID="125d2b6f93b70a5d2ff61c941c1e44128d6d847e82b7f526a8a6609caf970e11"
Mar 28 18:21:57bratk3s[3054412]: E0328 18:21:57.1795083054412pod_workers.go:1301] "Error syncing pod, skipping" err="failed to \"StartContainer\" for \"cub\" with CrashLoopBackOff: \"back-off 5m0srestartingfailed container=cub pod=cub-764bfbdfd-pmst4_home(d8884c82-0983-476a-ae97-ef6a89de2e08)\"" pod="home/cub-764bfbdfd-pmst4" podUID="d8884c82-0983-476a-ae97-ef6a89de2e08"
Mar 28 18:22:09bratk3s[3054412]: I0328 18:22:09.1798913054412scope.go:117] "RemoveContainer" containerID="125d2b6f93b70a5d2ff61c941c1e44128d6d847e82b7f526a8a6609caf970e11"
Mar 28 18:22:09bratk3s[3054412]: E0328 18:22:09.1801863054412pod_workers.go:1301] "Error syncing pod, skipping" err="failed to \"StartContainer\" for \"cub\" with CrashLoopBackOff: \"back-off 5m0srestartingfailed container=cub pod=cub-764bfbdfd-pmst4_home(d8884c82-0983-476a-ae97-ef6a89de2e08)\"" pod="home/cub-764bfbdfd-pmst4" podUID="d8884c82-0983-476a-ae97-ef6a89de2e08"
Mar 28 18:22:21bratk3s[3054412]: I0328 18:22:21.1784353054412scope.go:117] "RemoveContainer" containerID="125d2b6f93b70a5d2ff61c941c1e44128d6d847e82b7f526a8a6609caf970e11"
k3siles to consider 0 files...
home/300-home-mom-deployment.yaml
2.92K 100% 2.12MB/s 0:00:00 (xfer#1, to-check=29/60)
home/301-home-cub-deployment.yaml
3.71K 100% 3.53MB/s 0:00:00 (xfer#2, to-check=28/60)
sent 4.41K bytes received 130 bytes 3.02K bytes/sec
total size is 1.07M speedup is 236.91
Or push a fix and roll forward:
Closing words
The disaster recovery went well. The control node didn’t need a full teardown and rebuild, nor did the manifests require careful reorganization — but since the system was already down and the month had been rough, the cleanup felt worth it.
The deploy-manifests script still blows, even in its latest incarnation. A better version would merge all resources into a single YAML file and diff against the control node’s state, requiring a --fuck-me-up flag if more than two resources get deleted. But that’s incrementalism; what’s really needed is a proper continuous deployment solution with progressive rollouts and automated rollbacks. Until then, caution will have to suffice — and the knowledge that no data was lost helps.
Storing repositories only in Forgejo without GitHub mirrors felt risky at first, but this wasn’t the first disaster, and recovery went smoothly each time.
As for Taiga for Kanban: no. Teamhood’s free tier covers everything needed for now.
The fine folks at the self-directed research podcast will hear more about beardist next season. In the meantime, here’s a popular episode:




