n8n has been running in my homelab for over a year. It handles my morning brief, daily digests into SilverBullet, weekly retro synthesis, GitHub activity tracking, task aggregation — the whole personal productivity glue layer. It works. It also drives me quietly insane.
The problems aren't bugs. n8n runs fine. The problems are operational:
No git. n8n stores workflows in Postgres. The "export" button spits out JSON that looks like a format that never considered version control. Diffing two versions of a workflow is effectively impossible. When something breaks at 2am you're staring at a visual graph trying to figure out what changed, not a git diff.
Stateful by default. n8n's workflowStaticData lets you store things across runs inside the workflow itself. That sounds useful until you realize the state is invisible, unstructured, and unrecoverable if the node crashes or you accidentally clear it.
One runtime for everything. When the n8n LXC (container 228 in my setup) is down for maintenance, all 11 automation flows are dead simultaneously. There's no concept of isolating a misbehaving workflow.
Visual graphs don't compose. Every flow starts from scratch. The HTTP request to Memos, the auth to SilverBullet, the idempotent section-splice logic — copy-pasted into every workflow that touches those systems. No abstraction possible.
I'd been running swamp for my media curator pipeline for two days (see "Curating with swamp"). On May 21st I decided to port everything over.
At the point of migration, eleven flows handled the daily glue layer:
| n8n Flow ID | What it did | Swamp replacement |
|---|---|---|
CSOVRZr8c3PJW6Cz |
Morning brief → Memos | daily-morning @ 07:00 Phoenix |
0mTVjehrY2f8hYfM |
Memos + brief/review digests → SilverBullet | daily-digests-eod @ 23:55 Phoenix |
tslCxHMvE61ZleMX |
Tasks → SilverBullet (every 15 min) | tasks-refresh @ */15 |
DCoTTMsvl36hsLVd |
Daily review template seeder | daily-templates @ 05:00 Phoenix |
WCvasZo67l0L5XP1 |
Weekly retro | weekly-retro @ Mon 07:00 Phoenix |
wDZjgxwsdYEdFQKL |
Weekly review aggregator | weekly-review @ Mon 06:00 UTC |
| + 5 more | Today.md digest, memo synthesis, daily narrative, GitHub activity, template flows | 7 more swamp workflows |
All eleven gone in a single day. Thirteen swamp workflows now cover everything they did, plus a few things n8n couldn't easily do.
The process per workflow was consistent:
- Read the n8n flow's node graph, extract the business logic.
- Build an extension model in
extensions/models/<name>.tswith a Zod schema for inputs and outputs. - Wire it into a workflow YAML file.
- Smoke test against the real SilverBullet and Memos instances.
The extension models are TypeScript/Deno, which meant I could factor out shared code for the first time. SilverBullet's section-splice logic — read the page, find the ## Section header, replace content between it and the next ##, write back — lives in digest_memos.ts, ported once from the n8n helper code, and reused by digest_tagged_memo.ts, digest_today.ts, and digest_narrative.ts.
That alone was worth the migration. Four n8n flows, each with their own copy of the splice code. One swamp function.
In n8n, you can route different schedule triggers through branches in a single workflow. In swamp, each workflow file has exactly one trigger. This caught me when I tried to put the 05:00 Phoenix template seeder and the 07:00 Phoenix morning brief into one file.
Swamp said no. I split them into daily-templates and daily-morning. The workflow descriptions now explain why they're siblings:
Sibling of daily-templates (05:00 Phoenix); separate workflow because swamp
triggers are per-workflow, so 05:00 (template seeders) and 07:00 (morning brief)
can't share a trigger.
In retrospect this is better. In n8n the timing dependency between the template seeder (post the empty review skeleton) and the morning brief (which reads yesterday's filled-in review) was implicit — the 2-hour gap was load-bearing but invisible. Separate workflow files make it explicit: the 05:00 job has to finish before the 07:00 job runs.
The daily-digests-eod workflow at 23:55 does three things: splice the day's memos into the SilverBullet daily page, splice the #brief memo, and splice the #review memo. My first instinct was to parallelize these — they're reading from different sources, so why not run them concurrently?
Because they all write to the same file. SilverBullet's .fs API is a GET/PUT over HTTP with no locking. If two jobs read the file simultaneously, edit their respective sections, and PUT back, the second PUT overwrites the first's changes.
The fix: chain the jobs with dependsOn. Job 2 waits for job 1, job 3 waits for job 2. The section-splice function is idempotent — read-edit-write, not append — so ordering doesn't matter beyond serialization. The workflow description now says it directly:
All three jobs PUT the same daily page (yyyy-mm-dd.md), so they're chained
sequentially via dependsOn rather than parallelized — concurrent PUTs would
clobber each other.
Write that comment before the bug, not after. I wrote it after.
n8n let me set schedules in local time. "Run at 07:00" and I meant Phoenix time, n8n understood Phoenix time. Swamp cron is UTC.
Phoenix is UTC-7 year-round — Arizona doesn't observe DST, it stays on MST. So 07:00 Phoenix is 14:00 UTC. The math is easy; applying it consistently to every schedule and documenting it is the tedious part. I added timezone comments to every single trigger:
trigger:
schedule: "0 14 * * *" # 07:00 America/Phoenix (UTC-7, no DST)trigger:
schedule: "55 6 * * *" # 23:55 America/Phoenix (UTC-7, no DST)trigger:
schedule: "1 7 * * *" # 00:01 America/Phoenix (UTC-7, no DST)If I'd skipped those comments and come back to this codebase in three months, I'd have had no idea why the morning brief posts at 2pm UTC instead of midnight UTC. Document the timezone math. Every schedule.
The tasks flow in n8n used workflowStaticData to track first-sighting timestamps per task: when a task appears for the first time, record the date. When it reappears, compute age from that date.
Porting this was a design choice: rebuild the stateful tracking (using a swamp resource to persist the ledger between runs) or redesign it as stateless. I went stateless. Task age is now computed as "how old is the oldest memo containing this exact task line" from the pageSize=100 query window each run. The workflow comment documents the tradeoff honestly:
Unlike the n8n original (which tracked first-sighting per task in
workflowStaticData), this is stateless — age comes from the oldest memo
containing the exact task line, computed from the pageSize=100 window
each run. Side effect: ages cap at "100 memos ago" instead of growing
unboundedly, which actually matches the staleness intent.
Tasks open for more than 100 memos are old regardless of exact count. The label "old" is accurate. The n8n approach was more precise but fragile: state lived in Postgres, invisible, with no audit trail and no recovery path if corrupted. Stateless is simpler and more durable; precision wasn't worth the fragility.
Shared extension models. Every workflow that touches Memos uses the same @homelab/digest-memos type. The Zod schema enforces consistent inputs (memosBaseUrl, memosApiToken, timezone) across every caller. No copy-paste drift.
Idempotency throughout. Every digest method is a read-edit-write safe to run twice. This is a hard requirement when multiple workflows splice sections into the same file through the day. The section-splice function handles three cases: section already exists (overwrite in place), section doesn't exist (append), page doesn't exist (create). Zero state required, runs clean from cold start.
CEL expressions for wiring. Data flows between models via CEL expressions like data.latest("digest-memos", "sync_log").attributes.memoCount. No re-fetching data that already exists, no intermediate variables in the workflow YAML. The flow reads like a data dependency graph.
Git. Every model, every workflow, every config change is a commit. The migration itself is 11 commits dated 2026-05-21, each with a message like "Migrate Today.md digest from n8n to swamp." I can git log --oneline and see exactly what changed and when. I could not do this with n8n.
If you're running n8n for personal automation and you've ever wanted to git diff a workflow, extract shared helper code, or read a failure log instead of clicking through a visual graph — migrate.
The cost is one day of focused work per platform's worth of flows. The benefit is a codebase that can actually be reasoned about, versioned, and extended.
If you're running n8n for team workflows where non-technical users depend on the visual builder, keep n8n. Swamp workflows are YAML and TypeScript; there's no UI editor.
My n8n container (LXC 228) is still running. It's still listed in dns-policy.vhosts. Nothing routes to it anymore. I'm leaving it up for a week in case I missed something, then decommissioning it. Which I'll have to remember to add a rule for in the alert reactor. But that's another post.