Trading n8n for Kestra

For a while my homelab’s scheduled jobs lived in two places, and neither of them was a good home.

The weekly OS patching ran as a cron in my git server’s CI. The monitoring and the daily digest ran in n8n, a node-based automation tool I’d set up on the Docker VM. n8n is genuinely nice for wiring webhooks to APIs and clicking a flow together in an afternoon. The problem was never that it couldn’t do the job.

The problem was that I’d fallen for GitOps, and n8n is the opposite of GitOps. I want my infrastructure described as version-controlled text: a file I can diff, review in a pull request, and roll back. By that point most of the homelab already worked that way, and the automation tying it together was the one corner that didn’t. A workflow built by dragging nodes around a canvas and saved into a database is just not something you can put under git in any meaningful way.

So I went looking for an orchestrator where the workflow is a YAML file, retired the n8n workflows, and moved everything that runs on a schedule into Kestra. This post is about why that shape fits the way I like to run things, and the handful of things that fought me on the way in.

What n8n was good and bad at

n8n stores a workflow as JSON inside its own database, and you build it by dragging nodes around a canvas. That is a fast way to start. It is a slow way to live with something.

The parts that wore on me:

  • The workflow lives in a database, not in git. To review a change I had to open the canvas. To back it up I had to back up the database. There is no clean diff of “what changed in this job last Tuesday”, because the source of truth is a blob of node coordinates and connections.
  • It is built for app glue, not orchestration. Retries, schedules, and run history exist, but they are bolted onto a tool whose centre of gravity is “call this API when that webhook fires”. Running an Ansible playbook on a timer and reading the result felt like working against the grain.
  • Everything runs in the one process. A job that needs a specific version of Ansible or some CLI tool means installing it into the n8n container and hoping nothing else minds.

None of that is a knock on n8n for the thing it’s for. It just wasn’t the thing I needed.

What Kestra is

Kestra is a workflow orchestrator in the Airflow and Dagster family: flows made of tasks, with triggers, retries, and run history as first-class ideas. The difference that sold me is that a flow is a YAML file. That one choice fixes most of my n8n complaints at once.

A flow looks like this:

id: update-guests
namespace: homelab.maintenance

triggers:
  - id: weekly
    type: io.kestra.plugin.core.trigger.Schedule
    cron: "0 4 * * 0"            # Sundays, 04:00

tasks:
  - id: patch
    type: io.kestra.plugin.ansible.cli.AnsibleCLI
    # ... runs the playbook in a throwaway container

Because it’s text, the flows live in a git repo and get reviewed, diffed, and rolled back like any other code. And because every task runs in its own short-lived Docker container, a job that wants a particular Ansible image just asks for that image. Nothing leaks into a shared runtime.

It runs as its own container stack (the app plus a Postgres for its history) on my primary Proxmox node, in a small LXC of its own. I gave it Docker access on purpose: that disposable-container task runner is the whole point, so the app needs to be able to spawn containers.

How it fits together

flowchart LR
    repo["flows repo\n(self-hosted git)"] -->|"daily git poll"| sync["sync-flows\n(GitOps puller)"]

    subgraph stack["Kestra LXC"]
        kestra["Kestra"]
        pg["Postgres\n(run history)"]
        kestra --- pg
    end

    sync --> kestra

    kestra -->|"Ansible task runner"| guests["weekly: apt upgrade\nevery LXC guest"]
    kestra -->|"read-only SSH probes"| host["host checks:\nquorum, disk, drive,\nfailed units, RAM"]
    host -->|"alerts"| tg["Telegram"]

Flows live in git, not the UI

This is the part n8n could never give me. The flows sit in their own git repo, and a single flow called sync-flows pulls them into Kestra on a daily poll, deploying both the flow definitions and the helper scripts they use. Git is the source of truth: anything created in the live namespace that isn’t in the repo gets removed on the next sync.

So changing a job is git push, not clicking around a canvas and hoping I remember what I did. I can open a pull request against my own automation.

There is exactly one flow this doesn’t cover: sync-flows itself, because a puller can’t very well pull its own definition out from under its feet. That one I edit by hand, and it’s the only one. Everything else is GitOps.

One small thing worth knowing if you copy this pattern: I have the puller clone over plain HTTP from the local git server rather than HTTPS. Kestra’s JVM git client doesn’t trust my internal certificate authority, and an anonymous HTTP clone of a public repo sidesteps the whole problem without me wiring trust stores into a JVM.

What’s actually scheduled

Two kinds of job moved in.

The first is OS patching. The Ansible playbook that upgrades every container was already written (that was the last post); all that changed is the thing pulling the trigger. It used to be a cron in my git server’s CI. Now it’s a Kestra flow on a Sunday-morning schedule. I deleted the old CI schedule so the two don’t both fire.

The second is monitoring, which is the part that used to be scattered across n8n. A few small read-only probes SSH into the host on a timer and shout on Telegram only when something is wrong: the cluster losing quorum, the external media drive dropping off USB (it has done this before, and it takes two services down with it), a root filesystem creeping toward full, a crashed systemd unit, plus a weekly RAM report just so I have a number. Each probe is a plain Python script kept in the same git repo as the flows, so the logic gets reviewed like everything else.

These could have been Ansible too, but a read-only health check every fifteen minutes doesn’t need the idempotent-configuration machinery that makes Ansible worth it. A short script piped over SSH is the right size.

The bits that fought back

It went in cleanly enough that the friction is worth writing down, because most of it was non-obvious.

Kestra’s built-in SSH plugin couldn’t read my key. It uses a Java SSH library, JSch, which chokes on a modern OpenSSH-format ed25519 key with Invalid openssh v1 format. Rather than convert the key to an older format, I dropped the plugin and used a shell task that pipes the probe script into the system ssh client, which reads the key without complaint.

One command block, not a list. The Ansible task runner spins up a fresh container per entry in its command list, and this build of Kestra false-positives its own “was this killed?” check on the second container, reporting “Execution was killed during image pull” when nothing was killed. Keeping the whole script in a single block, so it’s one container, makes the problem disappear.

A sudo rule that silently never matched. One probe reads drive health with sudo, so I added a narrow sudoers entry pinned to the source IP. It never matched, with no error, just a password prompt that hung the job. The Host field in a sudoers line is the host running sudo, not the SSH client’s address. The fix was to drop the IP from the sudoers rule and pin access at the SSH key instead, with a from= restriction on the authorized key.

A successful git clone reported as an error. git writes its “Cloning into…” progress to stderr, so Kestra dutifully tagged a perfectly fine clone with a red ERROR. Took me a minute to stop trusting that label.

Telegram messages came through as raw JSON. The send task’s payload is the message text, not a JSON body. Hand it {"text": "..."} and your alert arrives with the braces and quotes intact.

Where it landed

Now there’s one place that lists every job that runs on a schedule, shows me whether the last run passed, and keeps its own history. Patching and monitoring both live there. The definitions live in git, so changing any of it is a commit I can review and revert.

n8n is still the right tool the day I want to glue three SaaS APIs together with a webhook. For the unglamorous job of running things on a timer and telling me when they break, a YAML-first orchestrator with its flows in version control turned out to be a much better fit.

Discussion