Skip to content

Folders and files

NameName
Last commit message
Last commit date

Latest commit

 

History

2 Commits
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

n8n-iac

Infrastructure-as-code for n8n. Author workflows as typed Python, compile them to wire-exact n8n JSON, catch broken wiring before deploying, and ship with a Terraform-style plan / apply lifecycle.

CI Python 3.12+ License: MIT Checked with mypy

with Workflow(uid="order-sync", name="Order Sync", active=True) as wf:
    Webhook(path="orders") >> Set(name="Shape", fields=[...]) >> HttpRequest(url=...)
$ n8n-iac plan --env prod
~ UPDATE Order Sync
    ~ /nodes/Shape/parameters/assignments  …
WARNING R5  output 1 ('silver') of 'Route' is not connected while other branches are

Why this exists

n8n workflows live as JSON blobs inside a UI. That means no meaningful code review — a one-node change reads as a 400-line diff — no environments, no CI, and no way to catch a silently-unwired branch until it quietly drops data in production.

n8n-iac moves the workflow into your repository:

n8n UI n8n-iac
Source of truth The instance's database Your git repo
Review 400-line JSON diff A readable Python diff
Environments Manual re-wiring One definition, per-env credential binding
Broken wiring Found in production Found offline, before deploy
Deploy Click "import" plan → review → apply

Project status

Alpha (v0.1.0). Usable and tested, with sharp edges worth knowing about up front.

What's proven: 524 offline tests at 100% line and branch coverage, mypy --strict clean, CI green on Python 3.12/3.13 across Linux, macOS, and Windows. A 12-test end-to-end suite runs against a real n8n instance (Public API 1.1.1) and covers create/read fidelity, update semantics, activation, pagination, plan/apply convergence, and both destroy tiers.

What's not: only 16 node types have full parameter schemas (others work through a permissive escape hatch, without validation), import emits canonical JSON rather than reconstructed Python source, the async client covers transport and resources but not the deployer, and there's no PyPI release yet. The e2e suite has been run against one n8n version, so older or newer instances may differ.

Breaking changes are possible before 1.0. If you deploy to something you care about, pin a commit.

Install

pip install git+https://github.com/SAKMZ/n8n-python-sdk.git

Requires Python 3.12+.

Quickstart

from n8n_iac.nodes import HttpRequest, Set, SetField, Webhook, Workflow

with Workflow(uid="order-sync", name="Order Sync", tags=("iac",), active=True) as wf:
    hook = Webhook(path="orders", http_method="POST")
    shape = Set(
        name="Shape Order",
        fields=[
            SetField("order_id", "={{ $json.body.id }}"),
            SetField("source", "webhook"),
        ],
    )
    forward = HttpRequest(
        name="Forward To ERP",
        url="https://erp.example.com/orders",
        method="POST",
    )
    hook >> shape >> forward

print(wf.to_json())        # wire-exact n8n JSON, importable as-is
print(wf.diagnostics())    # () -- this workflow is clean

>> wires nodes together. Node positions, node IDs, and webhookId are derived automatically — you never lay out a canvas by hand.

Expressions need the = prefix, exactly as in the n8n UI. "={{ $json.body.id }}" is an expression; "{{ $json.body.id }}" is a literal string that happens to contain braces. This catches everyone at least once. (Cond is the exception — it wraps a leading $ for you.)

What the validator catches

This is the part that earns its keep, and it runs fully offline — no instance, no network.

A Switch with a branch nobody wired up:

route = Switch(name="Route", rules=[
    Rule(when=Cond("$json.tier", "equals", "gold"),   output="gold"),
    Rule(when=Cond("$json.tier", "equals", "silver"), output="silver"),
])
Webhook(path="demo") >> route
route.out("gold") >> HttpRequest(name="Gold Path", url="https://example.com/gold")
WARNING R5  output 1 ('silver') of 'Route' is not connected while other branches are

An expression referencing a node that isn't actually upstream — which n8n resolves to undefined at runtime, silently, raising nothing of its own:

a = Set(name="First", fields=[SetField("x", "={{ $('Second').item.json.y }}")])
b = HttpRequest(name="Second", url="https://example.com/x")
hook >> a >> b   # 'First' runs BEFORE 'Second'
ERROR R6  expression in 'First' references 'Second', which is not a transitive
          ancestor -- this yields undefined at runtime

The rule set also covers unreachable nodes, dead-end transforms, illegal cycles (loop nodes exempted), port-index bounds, missing AI sub-node attachments, hardcoded secrets, and per-node rules for Webhook and HTTP Request.

Deploying

Describe your environments in n8n_iac.toml:

[project]
workflows  = "workflows.py:build_all"   # a callable returning your Workflow list
state_file = ".n8n-iac-state.json"

[environments.prod]
base_url    = "https://n8n.example.com"
api_key_env = "N8N_API_KEY"             # the env var NAME -- never the key itself
production  = true

[environments.prod.credentials]
erp-api = "17"                          # logical name -> credential id on that instance
n8n-iac validate                  # offline: compile + validate, no instance needed
n8n-iac plan --env prod           # what would change, as a colorized diff
n8n-iac apply --env prod          # after a confirmation prompt
n8n-iac destroy --env prod <uid>  # deactivate and unbind (--hard to delete)
n8n-iac import --workflow-id 42 --uid billing   # adopt an existing workflow

plan --detailed-exitcode exits 2 when changes are pending, so it drops straight into a CI gate; plan --json emits the machine-readable plan.

Credentials bind by reference, never by value: your Python names a logical credential (HeaderAuth("erp-api")), the TOML maps that name to an id per instance, and the API key itself only ever lives in an environment variable. No secret is written to a config file, a state file, or compiled output — and rule C1 fails the build if one gets hardcoded.

Two things to know about destroy

n8n's public API has no archive endpointisArchived is readable but nothing can set it. So destroy has two tiers: the default retires a workflow (deactivates it and drops it from your state file, leaving it inert on the instance), and --hard deletes it, which is irreversible and cascades to execution history.

Retiring unbinds identity, so a later apply of the same source creates a second workflow rather than reclaiming the retired one. You end up with two same-named workflows, one inert. That follows from the state file being the identity link, but it surprises people. If you meant "turn this off for now", set active=False in your source and apply — that keeps the binding. Use destroy when you mean it.

Determinism

Compiling the same source twice produces byte-identical JSON, on any machine, under any PYTHONHASHSEED. This is a tested property, not an aspiration — it's what makes plan diffs trustworthy and keeps a no-op deploy showing zero changes.

It falls out of a few deliberate constraints: node IDs and webhookId are uuid5-derived from your Workflow.uid (never random), every collection reaching output has a defined sort, and diagnostics have a total order. The suite includes a subprocess matrix that recompiles under different hash seeds and asserts the bytes match.

Contributing

Contributions are welcome — issues, bug reports, and pull requests alike.

The most valuable contributions right now:

  • Node schemas. 16 types are modeled out of hundreds. Adding one is self-contained: a params model, a registry entry, and tests.
  • Testing against more n8n versions. The e2e suite has only met one. If it fails against yours, that's a genuinely useful bug report — include your n8n version.
  • Bug reports with a minimal reproducing workflow. A 10-line Workflow(...) that misbehaves beats a long description.

Development setup, and the four gates that must pass:

poetry install
poetry run pytest --cov --cov-branch    # 100% line and branch coverage
poetry run mypy --strict n8n_iac        # zero errors
poetry run ruff check n8n_iac tests
poetry run python -m n8n_iac.client._async_codegen --check   # generated code is current

No Poetry? A plain venv works — pip install -e . plus the dev dependencies.

The end-to-end tests need a real n8n and are excluded by default. They only ever touch workflows they create (all named with an n8n-iac-e2e prefix) and delete them afterwards, so running them against an instance with real workflows on it is safe:

N8N_BASE_URL=http://localhost:5678 N8N_API_KEY=... poetry run pytest -m e2e

A few house rules worth knowing before you open a PR: prefer an explicit raise over assert for narrowing (assert creates a branch coverage can't reach), never iterate a dict or set for anything that reaches output, and if you change a validator rule, update the matching golden fixture — reviewing that diff by hand, since --update-golden will happily bless a regression.

License

MIT — see LICENSE. Not affiliated with or endorsed by n8n GmbH.

About

Infrastructure-as-code for n8n: author workflows as typed Python, compile to wire-exact JSON, validate offline, deploy with plan/apply.

Topics

Resources

Stars

Watchers

Forks

Releases

Packages

Contributors

Languages