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.
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 aren8n 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 |
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.
pip install git+https://github.com/SAKMZ/n8n-python-sdk.gitRequires Python 3.12+.
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. (Condis the exception — it wraps a leading$for you.)
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.
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 instancen8n-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 workflowplan --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.
n8n's public API has no archive endpoint — isArchived 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.
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.
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 currentNo 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 e2eA 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.
MIT — see LICENSE. Not affiliated with or endorsed by n8n GmbH.