The day began with maintenance too ordinary to deserve a paragraph. Seventeen containers pulled and recreated, ten and a half gigabytes of dead images pruned, then a full upgrade and a reboot. The box was down for twenty-six seconds, its first reboot since the fourteenth, and thirty containers came back with nothing restart-looping.
Four minutes later I opened a fresh session and typed six words: are all my docker containers updated? The answer was yes — twenty-seven of twenty-nine images current, the two flagged stale being digest pins the photo stack deliberately holds. Except I'd just spent half a Friday finding that out, and I'd have to spend it again next month. Thirty-six minutes later I was asking for a machine that would do the asking for me.
Teaching the server to ask on its own
The shape I wanted: something wakes before dawn, checks host packages and container images, and sorts each one into apply, skip, or come ask me — with a model reading the actual changelogs and a Discord message when the call is above its pay grade.
n8n went on port 5678 as the scheduler. The design decision I'd defend to anyone is that the workflows are dumb: they're thin SSH wrappers, and every scrap of logic lives in shell scripts in ~/bin, so I can run any stage by hand instead of poking at boxes on a canvas. The judge is a headless Claude call with web search, about twenty cents a run, working from plain-English criteria — including one that says if you're uncertain about a major bump, say skip and explain why, and a human can apply it manually. Giving a scheduler root deserved care, so the sudoers rule is exact-match with no wildcards anywhere: a wildcard there is an invitation to inject options as root.
The approval loop took longest to get honest. Every conditional placed after a Discord call saw nothing at all, because a Discord webhook responds with an empty body, which had quietly created a latent bug where the reboot-required branch could never have fired. It would have looked fine forever.
The verdict that never arrived
Here is the one I'll be telling people about.
Twice, at 16:25 and again a minute later, the judge ran against a real batch of eleven pending host packages. Both times it came back clean: update, risk low, with reasoning that all eleven were routine patch and point-release bumps, no removals and no major version jumps. Both times, the pipeline reported skip.
The verdicts were never wrong. The parsing was. Somewhere in the orchestrator I'd written a bash default-value expression of the form ${JUDGE:-{}}, meaning "the judge output, or an empty JSON object if it's missing". Bash doesn't read it that way. It takes the default as { and treats the second brace as a literal character to append. So a valid JSON verdict came out of the substitution with a stray closing brace stapled to the end, failed to parse, and fell through to the safe branch. The safe branch is skip.
That is the failure mode worth being frightened of. Nothing errored, nothing alerted, the workflow succeeded, Discord stayed quiet, and the automation did absolutely nothing in a way that looked exactly like a well-behaved system with no work to do. An automation that fails safe is still an automation that silently does nothing. And the reason I built it was that I couldn't tell by looking whether my containers were current — so a pipeline that lies by omission is worse than no pipeline, because now I trust it.
The fix was small: default to a null literal instead of a brace, then check the parsed result is the type you expected before acting on it. The lesson is bigger. Anything that can decide "do nothing" has to prove it decided that, rather than merely arriving there.
A README nobody had to write twice
Mid-afternoon I asked for a README covering the whole homelab, and for as many agents as it took to find out what the homelab actually contains. Ten went out in parallel and all ten came back, and the surprise was two live problems nobody had gone looking for: a stale boot cron still starting a deprecated site and crash-looping against a port another service now owns, and a reverse proxy squatting on ports 80 and 443 while fronting nothing. Sending ten readers through your own house is an audit whether you meant it to be or not.
The follow-on was to stop writing docs by hand. A watcher listens for Docker container-create events, waits sixty seconds so a burst of containers counts as one change, and fires a webhook. On the other end a script redacts anything resembling a token out of the compose file and has a model write the page. The insight that made one watcher cover both paths: a terminal docker compose up and a Portainer stack deploy both recreate containers with compose labels, so a create event is the universal trigger. Nine pages bootstrapped in about six minutes.
The Claw that wouldn't link
Then a detour with a real wrong answer in it. My handheld PC had a USB-C 2.5-gigabit adapter plugged into the office access point's spare ethernet port, and was getting nothing.
My first diagnosis was confident and wrong. I said it was the admin gate: a firewall policy that only lets whitelisted machines onto the management network, which neatly explains a device with a cable and no service. On any other day that's the answer. Not this one. Address sweeps of the subnet found no trace of the machine at all, so it never got as far as asking for an address and the firewall never had the chance to refuse it. The port was down at zero link speed, and stayed down through autonegotiation, forced 2.5 gigabit and forced 1. When a link won't come up at any speed the fault is on the far side of the cable — most likely no driver for that Realtek chipset on the Windows install, so the adapter never initialises.
There was also a self-inflicted casualty. Twenty minutes earlier, without noticing, I had unplugged a small five-port switch to free up that port, and it was sitting there dark. Since it only does one gigabit it will never link on a port I've now forced to 2.5, so the override has to come off before it goes back. I went looking for one dead link and made a second one.
Built at six, shelved at nine
In between, I built a thing and then decided not to run it, which I want to record as a decision rather than a failure. The idea: drop a phone photo of a work-order sheet in a folder, get the handwritten numbers read out, validated against a field schema with types, ranges and a confidence floor, and written into its own styled tab in an Excel workbook. It worked. Three photo-realistic test sheets in three formats each extracted sixteen out of sixteen fields with zero errors, a pure-noise image was rejected with every field null at zero confidence, and re-dropping a processed sheet produced no duplicate tab and no duplicate index row.
Three hours after asking for an always-on service, I asked for it to be taken offline and filed as a project I could come back to. Not deleted — stopped and disabled, unit file kept, one command brings it back. The honest reason is that it reads a form layout invented for the demo. My actual work-order sheet has never been configured, and running a live service against a form that doesn't exist is theatre.
Thirty-three minutes to a git forge
At 21:07 I asked for agents to research Forgejo and come back only with something viable. I sent the prompt twice inside twenty-five seconds, which tells you how much patience I had left.
Three research agents reported within five minutes. Forgejo is a hard fork of Gitea since early 2024, run by a German non-profit; version 16 is out but non-LTS with an end of life this October, so the LTS release it was. The inventory was the useful part: only four repositories on the box are mine, and there was exactly one hard coupling worth protecting — Vercel auto-deploys from GitHub, and Vercel cannot talk to a self-hosted forge. That constraint decided the architecture. Forgejo becomes primary, GitHub becomes a push-mirror target, and every push still lands on GitHub, so nothing had to move. Six repositories migrated, one with all four of its releases.
Two mirror syncs returned HTTP 500 and the cause was mundane: the new git-SSH host on port 2222 wasn't in known_hosts, so host-key verification failed behind an unhelpful status code. A branch pushed to Forgejo then appeared on GitHub in under thirty seconds, and deleting it propagated too. Backups went on a nightly timer to the NAS, proven with a 433 MB dump landing and its monitor going green.
Prompt at 21:07, "Forgejo is live" at 21:40. Thirty-three minutes for research, decision, deployment, six migrations, mirrors, backups and monitoring. Fast enough that I checked the timestamps twice.
Except one of those things I didn't do. At 21:21, two minutes after the container came up, a documentation page for the new stack appeared on disk, with the live container line in it showing an uptime of fifty-nine seconds. The watcher I'd built three and a half hours earlier had seen a container it didn't recognise, waited its sixty seconds, and written the page. Nobody asked. I found out by looking at a file modification time.
The day started because I couldn't tell whether my containers were current without going and looking. It ended with the server updating itself on a schedule, asking permission when it isn't sure, and documenting things I install before I've finished installing them. I'm no longer the one who checks. I'm the one who gets asked.