Let’s talkLet’s talk

July 21st, 2026

The machine writes the infrastructure now

Krzysztof Łuczak

Krzysztof Łuczak

8 mins

We manage Boar's servers with pyinfra instead of Ansible for a reason most tool comparisons skip: the config is increasingly drafted by AI agents and reviewed by us, and a real programming language survives that handoff better than a YAML dialect with a templating engine inside it.

The machine writes the infrastructure now

Boar's servers are configured by a directory of Python files. Adding a package to a node, hardening its SSH, opening a firewall port: each is a Python edit, and more and more often the first draft of that edit is written by an AI agent and reviewed by a person. That second fact is why we run pyinfra rather than Ansible.

Most pyinfra-versus-Ansible arguments are about syntax taste or execution speed. Ours is about who writes the config now, and who has to read it back. An agent, like the engineer checking its work, handles a real programming language better than it handles a YAML dialect with a templating language stuffed inside it. That is the whole case, and it is worth being precise about, because the obvious version of it is wrong.

Two tools that look identical on paper

Start with what does not separate them. Both are agentless: they connect over SSH and need nothing installed on the target beyond a shell and an SSH daemon. pyinfra states it plainly ("the target hosts need nothing aside the SSH daemon running"), and Ansible's pitch is the same, avoiding "the installation of additional software across IT infrastructure" and using SSH with existing credentials. Both are idempotent: describe the desired state, run repeatedly, and a fully-provisioned host reports no change. Both preview before they act. pyinfra has --dry for a per-host diff of every operation; Ansible has --check and --diff.

The split is the substrate. An Ansible deploy is a set of YAML playbooks with Jinja2 for anything dynamic. A pyinfra deploy is Python. Operations are function calls, facts are lookups, and control flow is the language's own. Here is how our baseline decides what to run on a given host:

from pyinfra import host, local

# Steady-state baseline, applied to every host.
local.include("deploys/basics/_all.py")

# Per-group deploys, gated on group membership.
if "optimism" in host.groups:
    local.include("deploys/optimism/deploy.py")

That is an if statement and an include. There is nothing to learn about how it evaluates, because it is Python evaluating the way Python always does. The Ansible equivalent leans on when: conditionals and import_tasks, which are a control-flow system built on top of YAML rather than the host language's own. Both work. Only one of them is a construct the reader already knows cold.

The obvious argument is the wrong one

The tempting claim is that models simply know Python better than YAML, so they write it better. That is not quite true, and pretending it is would fall apart on first contact with anyone who has run both tools.

Python is one of the most represented languages in the public code these models trained on. It was the top language on GitHub in 2024 and sits second only to TypeScript in 2025. But Ansible is not obscure either, and here the comparison cuts against us: there are far more public Ansible playbooks than pyinfra deploys. pyinfra is comparatively niche, roughly 6k GitHub stars against Ansible's ~70k. On raw familiarity with the specific tool, an agent has seen more Ansible.

That is why familiarity is the wrong thing to measure. The deciding factor is feedback: what the agent and the reviewer can check as the code is written, and how early a mistake shows up.

A pyinfra deploy is a Python program, so the tooling built for Python applies to it before it ever reaches a server. A linter flags an undefined name; the interpreter refuses to import a call that does not exist; a type checker, if you run one, catches a bad argument. When an agent drafts a deploy and gets one of these wrong, the feedback is local and precise, and it lands in the loop the agent is already working in rather than on the host mid-run.

YAML plus Jinja2 has linters too, ansible-lint and yamllint, and they catch real problems. What they cannot catch is the class of mistake the templating layer invites: a variable that is defined on some hosts and not others, a Jinja2 expression that is valid syntax but wrong at runtime, a type that only reveals itself when the value reaches a module. Those surface on the host, mid-run, which is the most expensive place to learn them. The DSL turns a language error into a runtime error, and a runtime error into an incident.

Note

This is a claim about the feedback loop, not about intelligence. A careful engineer writing Ansible by hand hits fewer of these than a fast agent does. The point is that an agent produces more drafts per hour than a human, so the value of catching its mistakes cheaply, before they run, scales with how much of the work it does.

What a real language buys you

The gains show up wherever the task needs actual logic. Our Docker baseline installs lazydocker from a pinned GitHub release, verified by checksum, and it has to pick the right checksum for the host's architecture:

_LAZYDOCKER_VERSION = "0.25.2"
_LAZYDOCKER_SHA256 = {
    "amd64": "0d9dbfc26068b218e7ed84b104748cadc6e3cf733c0afd35465306fb39b9523c",
    "arm64": "005c38b685aaa557e7d646d83a3dadb5024340eeed8c6a2e1949eee6f530de23",
}

if installed_version != _LAZYDOCKER_VERSION:
    arch = host.get_fact(Command, command="dpkg --print-architecture")
    if arch not in _LAZYDOCKER_SHA256:
        raise Exception(
            f"lazydocker is only pinned for {sorted(_LAZYDOCKER_SHA256)} "
            f"(host reports {arch!r}); add its sha256 from the release checksums."
        )
    files.download(
        name="Download lazydocker (pinned, sha256-verified)",
        src=f".../v{_LAZYDOCKER_VERSION}/lazydocker_..._{arch}.tar.gz",
        dest=tarball,
        sha256sum=_LAZYDOCKER_SHA256[arch],
        _sudo=True,
    )

A dictionary keyed by architecture, a membership test, and an exception with a message that tells the next operator exactly what to do. Every piece of that is ordinary Python. An agent generating it reaches for patterns it uses everywhere, and a reviewer reads it as code, not as a YAML encoding of code. Expressing the same guard in a playbook means a when: on the architecture fact, a fail: task with a templated message, and a variable lookup into a map, each written in the DSL's dialect of those ideas.

Secret access is the same story. There is no vault ceremony and no custom lookup plugin, just a Python function that shells out to sops and caches the result:

@functools.lru_cache
def _load_file(scope):
    path = os.path.join(_REPO_ROOT, "secrets", f"{scope}.enc.yaml")
    if not os.path.exists(path):
        return {}
    raw = subprocess.check_output(
        ["sops", "-d", "--output-type", "json", path], env=_sops_env()
    )
    return json.loads(raw)


def get(key, scope="all"):
    """secrets.get('rpc_password', scope='optimism')"""
    data = _load_file(scope)
    for part in key.split("."):
        data = data[part]
    return data

Because the config is Python, functools.lru_cache and subprocess are right there. When we later swap the secret backend, we rewrite this one file and the deploy code that calls secrets.get never changes. All of that is plain Python. pyinfra's job here is to stay out of its way.

Where Ansible is the better tool

None of this makes Ansible the wrong choice. For a large class of work it is plainly the better one, and the honest version of this argument has to say where.

Ansible's module ecosystem is enormous, several thousand collections on Ansible Galaxy, each bundling modules that other people write, test, and keep idempotent. That is real work you do not do. When a task fits an existing module, the agent writes a short, schema-constrained call that is genuinely harder to get wrong than free-form Python, because the module's own contract fences it in. pyinfra hands the agent more rope, and rope cuts both ways.

Two of the things we solved in Python are cleaner in Ansible, and I will not pretend otherwise. Restarting a service only when its config changed is a one-liner with handlers and notify, a pattern Ansible designed for exactly this. Avoiding a needless apt-get update on every run is a declarative cache_valid_time on the apt module. We express the second one as a plain Python guard:

update=not all(pkg in installed for pkg in packages)

It reads fine, but it is a line of logic we own where Ansible ships a tested option. There are more cases like it. pyinfra also has rougher edges: its paramiko-based SSH connector lacks an IdentitiesOnly equivalent, so pinning a single agent key, which is one inventory line in Ansible via ansible_ssh_common_args, cost us a small Python helper. And Ansible's ubiquity is itself a feature: more operators know it, ansible-lint is a mature standard, and the tooling around it is deeper.

Who should pick which

If you run a large fleet, lean on the module ecosystem, and have operators who are not programmers, Ansible's declarative surface and the modules behind it are worth more than anything in this article.

We are the other case. A small team, a handful of custom blockchain nodes, and engineers who spend their days in TypeScript rather than Python. That last detail cuts in pyinfra's favor: a general-purpose language reads fluently to anyone who writes code, where a YAML DSL is a dialect you pick up only for this one job. The config is increasingly drafted by agents and reviewed by us, and the substrate decides how much we can trust what the loop produces. We preview every change as a full diff before it runs, we read the change back as ordinary Python, and the language's own tooling is there the day we want to enforce more of it. A YAML dialect that defers its errors to the host gives us none of that. The machine writes more of our infrastructure every month. We would rather it wrote in a language we can both read.


AKENA is a blockchain engineering studio. We build the infrastructure AI agents run on: agent-facing RPC and MCP endpoints, on-chain data pipelines, and the products on top of them. If you're building systems that machines have to operate as well as read, we should talk.

Let’s talk

Bring us your problem, we’ll help design the system. No hype, just engineering.