[CODE]

Replacing n8n Queue Mode with GitHub Actions

How I dropped four Railway containers and a self-hosted n8n setup in favor of a cron-triggered GitHub Actions workflow — and why I should have done it sooner.

5 min read
github-actions n8n automation devops

The B&Bs I work with don’t have IT departments. When an automated system breaks, they don’t notice through a monitoring alert — they notice because a client didn’t get their report. That’s a bad way to discover an infrastructure problem.

Late last year I built a Google Ads automation system for a few of these clients running on n8n Queue Mode. The system was supposed to run silently every Monday: pull ad performance data, generate a report, send it out. Nobody needed to think about it. That was the whole point.

In practice, I was the one who ended up thinking about it — too often, and usually on weekends.

What the n8n Setup Looked Like

To run n8n in queue mode you need four containers: the main app, a worker, a webhook receiver, and a Postgres database. I was hosting all of this on Railway. The monthly cost was around $15–20 — not a dealbreaker for a client project, but the real cost wasn’t the money.

The real cost was maintenance time. Every time n8n pushed a new version, something broke. Credentials would need to be re-entered. A workflow node API would change slightly. The Postgres connection pool would behave differently. I’d spend a couple of hours on a Sunday debugging a system that was supposed to run automatically.

Four containers to run one cron job, once a week.

The Wake-Up Moment

The thing that finally pushed me to migrate was a n8n version upgrade that silently changed how it handled HTTP Basic Auth in one of my nodes. The workflow appeared to succeed in the UI, but the actual API call was failing.

I found out because a client asked why their report hadn’t arrived.

That sentence is the problem. The failure mode wasn’t caught by the system — it was caught by a client noticing an absence. I spent three hours debugging a visual workflow editor trying to figure out what changed. At some point I thought: if this were just a Python script, I could have written a unit test for this. I could have caught it before it hit anyone.

The GitHub Actions Version

The migration took about a day. The whole thing is now a Python script and a workflow YAML file.

name: Google Ads Weekly Report

on:
  schedule:
    - cron: '0 1 * * 1'  # Monday 09:00 Taipei time (UTC+8)
  workflow_dispatch:

jobs:
  run-report:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4

      - name: Set up Python
        uses: actions/setup-python@v5
        with:
          python-version: '3.11'

      - name: Install dependencies
        run: pip install -r requirements.txt

      - name: Run Google Ads report
        env:
          GOOGLE_ADS_DEVELOPER_TOKEN: ${{ secrets.GOOGLE_ADS_DEVELOPER_TOKEN }}
          GOOGLE_ADS_CLIENT_ID: ${{ secrets.GOOGLE_ADS_CLIENT_ID }}
          GOOGLE_ADS_CLIENT_SECRET: ${{ secrets.GOOGLE_ADS_CLIENT_SECRET }}
          GOOGLE_ADS_REFRESH_TOKEN: ${{ secrets.GOOGLE_ADS_REFRESH_TOKEN }}
          CUSTOMER_ID: ${{ secrets.CUSTOMER_ID }}
          NOTIFY_EMAIL: ${{ secrets.NOTIFY_EMAIL }}
        run: python scripts/weekly_report.py

The workflow_dispatch trigger was a small but important addition. It lets me run the job manually from the GitHub UI without waiting for Monday — useful when a client asks for an ad-hoc check mid-week.

Credentials moved into GitHub repository secrets. No more n8n credential vault, no more wondering if Railway’s environment variables are being injected correctly.

What Actually Got Better

Zero standing cost. GitHub Actions gives you 2,000 free minutes per month on public repos, and even on private repos the free tier is generous for a job that runs four times a month. I went from ~$20/month to $0. For a system serving small B&B clients, that matters — I’m not trying to pass infrastructure costs back to clients running on thin margins.

The code is version-controlled. This sounds obvious but it wasn’t true before. With n8n, the workflow lived in the Postgres database. If I needed to roll back a change I’d have to restore from a database snapshot. Now it’s just git revert. When something breaks, I have a clear history of what changed and when.

I can test it locally. The Python script runs fine on my machine. I can pass in test credentials and check the output before pushing. That’s not something you can easily do with a n8n visual workflow. The silent Auth failure I described earlier would have been caught in local testing — you’d run the script, get an auth error, fix it before it ever reached production.

Failures are visible. GitHub Actions sends email notifications when a job fails. The failure mode is now “I get an email immediately” rather than “a client notices an absence days later.” That’s a meaningful operational difference when you’re running automated systems for people who aren’t checking dashboards.

What I Gave Up

The visual editor is genuinely nice for non-developers. If I ever needed to hand this system off to someone without coding experience, the n8n UI would be much friendlier than a Python script and YAML.

Also, n8n’s error handling and retry logic is baked in. In GitHub Actions I had to write that myself — add try/except blocks, decide what counts as a retryable error, figure out how to send a failure notification. It wasn’t hard, but it was work I didn’t have to do before.

The Actual Lesson

The infrastructure mismatch was the real problem. n8n Queue Mode is a serious piece of software — it’s designed for teams building complex, event-driven automation with multiple concurrent workflows. I was using it to run one Python script, once a week.

For a simple weekly batch job that needs to be reliable and cheap to maintain, a cron job is the right tool. Not everything needs to be an always-on service, and the operational overhead of running one compounds when your clients are small businesses with no tolerance for unexplained silences.

The switch took a day. The peace of mind was immediate.