[CODE]

Four Containers for a Weekly Cron: Why I Dropped n8n Queue Mode

An LLM-powered keyword management system for independent B&Bs was burning money on four always-on containers for one weekly execution. The real cost wasn't money — it was the week of silence when one container failed invisibly.

6 min read AI-generated
github-actions n8n automation devops

Independent B&Bs in Hengchun don’t compete with OTAs on budget or brand — those battles are already lost. OTA commission rates are higher than most operators realize, their ad spend dwarfs what any single property can field, and their teams optimize campaigns professionally, full-time. The only lever an independent property actually controls is campaign precision: tighter keyword targeting, cleaner attribution, faster iteration on what’s driving direct contact versus what’s just burning budget.

The system I run for several operators there exists to make that precision systematic. Every Monday it pulls live keyword performance data from the Google Ads API via GAQL, diffs it against a Google Sheets keyword inventory to identify what’s active, what’s new, and what’s been removed, then passes the full picture to Claude for analysis. The LLM returns structured JSON: a single MVP keyword, a watchlist, add suggestions, and remove suggestions. A 14-day new-keyword protection rule — encoded in the system prompt and re-enforced as a hard filter in Python (the LLM occasionally ignores prompt constraints under data pressure) — prevents any keyword added in the last two weeks from appearing in the removal list regardless of how its early numbers look. The analyzer emails an HTML summary with a task ID. I review it and manually trigger the executor workflow in GitHub Actions with the task ID and an add or remove action. Nothing touches the live ad account until I approve.

For the first several months, all of that ran inside n8n Queue Mode on Railway.

What Queue Mode actually required

n8n Queue Mode is four containers: the main application, a worker, a webhook receiver, and a PostgreSQL instance. In my setup, Postgres wasn’t load-bearing for any business logic — n8n used it exclusively for LangChain’s chat memory, giving the AI state across runs. The actual keyword inventory and task history lived in Google Sheets. Remove the AI chat memory requirement, and there was no structural reason Postgres needed to exist.

The other three containers existed because Queue Mode requires them — not because the problem required them.

The approval gate was a button embedded in the email, pointing to a Railway webhook URL that had to stay live at all times to remain clickable. Four containers running continuously for one weekly execution and one human approval action.

Monthly cost: ten to fifteen dollars. Annualized, that’s a small number. But the cost that mattered wasn’t money — it was surface area.

The week it went quiet

An n8n version bump changed how the HTTP Basic Auth node serialized credentials for a specific request type. The node authenticated, returned 200, and moved on. The Google Ads API on the other end returned an empty response. The downstream node received the empty response, logged zero results, and exited cleanly. No error. No exception. Every node in the visual editor was green.

Seven days passed before I found out. One of the operators messaged asking where the Monday analysis was.

I spent three hours in the visual debugger before accepting that the problem wasn’t anywhere the tool could show me. The auth node had done what it was contracted to do: authenticate and return 200. Whether the response body contained what the downstream node needed is not in that contract. The failure lived in the gap between the abstraction layers — not in any one node, and not on any surface the visual editor could render. I was debugging the debugging environment, not the problem itself.

The migration was proportional to the actual problem

The Python scripts already existed. n8n was wrapping them in a visual interface and a distributed infrastructure they didn’t need.

Moving to GitHub Actions meant: credentials into repository secrets, a cron schedule at 0 1 * * 1 UTC (Monday 09:00 Taipei), workflow_dispatch on the executor workflow so I can approve keyword mutations by entering a task ID directly in the GitHub Actions UI. The Railway webhook URL is gone. The approval step now requires me to navigate to GitHub, paste the task ID from the email, and trigger the run — a slight UX regression from clicking a button, which I accepted in exchange for eliminating the always-on infrastructure that webhook required.

One day, one commit. All four containers gone. Google Sheets became both the human review surface and the state store. The workflow definitions moved from Postgres into git — reviewable in a pull request, reversible with git revert, runnable locally without any extra tooling.

Standing monthly cost: zero, plus pennies in LLM API calls for four weekly analyses.

The only metric that matters for a client-facing system

No pipeline is immune to failure. Google Ads changes its API behavior. OAuth refresh tokens expire at unpredictable intervals. The Claude system prompt needs tuning as each property’s competitive landscape shifts. A dependency version bumps in a way nobody anticipated. Every one of these is a foreseeable failure mode.

The question is never whether it fails. It’s who finds out first.

Under n8n Queue Mode: an operator messaged me after seven days of silence. Under GitHub Actions: a failure email arrives within minutes, with the full run log attached. That’s not a difference in response speed — it’s structural. A failure I find first can be fixed before it becomes visible. A failure the operator finds first is already a conversation about where their numbers went.

A single missed analysis, fixed quietly, doesn’t erode anything. Seven days of silence is a different situation. Trust accumulates in both directions, and the weekly delivery is the only tangible artifact the operator can point to as evidence the service exists. When it doesn’t arrive, the campaigns are still running and the budget is still spending — none of that is visible to them. The delivery is the product. Silent failure for a week means the product didn’t exist for a week.

When n8n is the right choice

n8n’s visual editor is a genuine advantage when the people reading and modifying workflows are not engineers. Its error routing is more expressive than GitHub Actions: individual node retries, failure branching, stateful webhook queue management. For workflows that live at the boundary between technical and non-technical stakeholders, that accessibility is load-bearing.

For pipelines that are code from start to finish — where every person who will ever read them knows what a GAQL query is and can read a Python stack trace — the visual layer adds friction without adding safety. Every abstraction carries an observability cost. In this case, I paid for it with seven days of invisible failure.


A Monday cron job has no business requiring four containers to stay alive on Sunday.