Puppet to Ansible: how the concepts map, and where the mapping breaks

Both tools do the same job. You describe how a machine should be configured, you keep that description in version control, and the tool makes the machine match. Most of what you learned from Puppet still applies.

The problem is the part that does not. Four differences are structural rather than cosmetic, and each one produces a specific kind of bad Ansible that is written almost exclusively by people who know Puppet well. This article covers the mapping first, then the four places where the mapping actively misleads you.

I moved because of the licensing change, not because of the tooling.

When Perforce put new Puppet packages behind a EULA and capped free use at 25 nodes, the calculation changed. Not because of the cap itself. Because the open-source version I had been running for years stopped being a thing I could rely on continuing to exist in the form I knew. OpenVox exists and is a reasonable answer, and I looked at it seriously. But once I was going to have to touch every node anyway, the question stopped being "which Puppet" and became "is this still the right tool."

I simply could not see myself in the Puppet space anymore. So I moved.

What follows is the mapping I wish I had understood before I started, and the four differences that made my first attempt a write-off.

The parts that map cleanly

PuppetAnsibleNotes
ManifestPlaybookThe file you run
ResourceTaskOne unit of desired state
Resource typeModulepackage, file, service all exist in both
Class / moduleRoleReusable, parameterised, distributable
Node definitionInventory + group_varsWhich machine gets what
Hieragroup_vars / host_varsData separated from logic
Facter factsansible_factsGathered from the target machine
ERB / EPP templatesJinja2 templatesDifferent syntax, same idea
notify and refreshnotify and handlersNearly identical semantics
Puppet ForgeAnsible GalaxyCommunity modules and roles
--noop--checkDry run
Exported resourcesno direct equivalentSee below

Side by side, a resource and a task look like the same thought written twice:

# Puppet
package { 'nginx':
  ensure => installed,
}

file { '/etc/nginx/nginx.conf':
  ensure  => file,
  content => template('nginx/nginx.conf.erb'),
  notify  => Service['nginx'],
}

service { 'nginx':
  ensure => running,
  enable => true,
}
# Ansible
- name: Install nginx
  ansible.builtin.package:
    name: nginx
    state: present

- name: Deploy nginx configuration
  ansible.builtin.template:
    src: nginx.conf.j2
    dest: /etc/nginx/nginx.conf
  notify: Restart nginx

- name: Ensure nginx is running
  ansible.builtin.service:
    name: nginx
    state: started
    enabled: true

If that were the whole story, migration would be a syntax exercise. It is not.

Break 1: a graph became a list

This is the difference that changes how you think, so it is worth being precise about.

Puppet compiles your manifests into a dependency graph. You declare relationships with require, before, notify, subscribe or the chaining arrows, and Puppet works out a valid execution order from the graph. The order of statements in the file is mostly irrelevant. You describe what depends on what, and the tool decides when.

Ansible has no graph. Tasks run in the order they appear in the file, top to bottom, and that is the entire model.

Two mistakes follow from this, and ex-Puppet people make both.

Trying to declare relationships. There is nothing to declare. If the config file must exist before the service starts, put the config task above the service task. That is the mechanism.

Assuming something will resolve it. Nothing will. If a role assumes a package another role installs, and the second role runs first, the play fails. Ansible will not reorder anything to help you.

The honest trade: Puppet's graph is more expressive and catches ordering problems at compile time. Ansible's list is simpler to reason about, because execution order is exactly what you see on screen. Once you stop looking for the graph, reading an Ansible playbook is easier than reading a Puppet module. But you have to stop looking.

Break 2: idempotency is now your job

This is the biggest source of bad Ansible written by people with a Puppet background.

In Puppet, idempotency is a property of the resource type. package { 'nginx': ensure => installed } cannot run twice with different effects, because the type is built around checking current state before acting. You do not think about it because you cannot get it wrong.

Most Ansible modules behave the same way. package, file, template, service, user, lineinfile all check state and report ok or changed honestly.

Then there is command and shell, which have no idea what they are doing. They run the command. Every time. And report changed, every time.

# Wrong: runs on every play, reports changed on every play
- name: Extract the release archive
  ansible.builtin.command: tar xzf /tmp/app.tar.gz -C /opt/app
# Right: skipped entirely once the marker exists
- name: Extract the release archive
  ansible.builtin.command:
    cmd: tar xzf /tmp/app.tar.gz -C /opt/app
    creates: /opt/app/VERSION

Three guards do most of the work. creates: skips the command if a path exists. removes: skips it if a path does not. changed_when: lets you decide from the output whether anything actually changed, and failed_when: does the same for failure.

- name: Check the application version
  ansible.builtin.command: /opt/app/bin/version
  register: version
  changed_when: false        # a read never changes anything
  check_mode: true

The rule that keeps you honest: run the playbook twice, and the second run should report zero changed. If it does not, something is lying about its state, and you now have a playbook that cannot tell you whether your infrastructure has drifted. That signal is worth protecting, because it is the only drift detection Ansible gives you.

Break 3: nothing is compiled

Puppet compiles a catalog before it touches the node. That compilation step does real work for you.

It fails early. A syntax error, a missing class, an undefined variable, a bad type all surface before any change is applied. The node is untouched.

It rejects duplicates. Declaring the same resource twice is a compile error, and this is a genuine safety property. Two Puppet modules cannot both manage /etc/nginx/nginx.conf with different content, because Puppet refuses to build the catalog.

Ansible has neither. Tasks are evaluated as they run, so a typo in task forty is discovered after tasks one through thirty-nine have already changed the machine. And nothing stops two roles from managing the same file. They will both run, in order, and the last one wins. No error, no warning, just a file whose contents depend on role ordering.

Partial mitigations, none of which fully replace compilation:

  • --syntax-check catches YAML and structural errors before running.
  • ansible-lint catches a wider class of problems, and is worth putting in CI.
  • --check --diff shows what would change, though it is unreliable on a first run because tasks that depend on earlier changes cannot be evaluated.
  • Molecule tests roles against a real container before they reach a real machine.

Duplicate management is the one with no tooling answer. It is a discipline problem: decide which role owns which file, and write it down.

Break 4: convergence goes away

The Puppet agent wakes up on a schedule, applies the catalog, and corrects whatever drifted. Someone edits a config by hand at two in the morning and it is quietly reverted within half an hour. A machine that was offline for a week fixes itself when it returns.

Ansible runs when you run it. Between runs, drift accumulates and nothing corrects it.

This changes what a playbook is. A Puppet manifest describes a state that is continuously enforced. An Ansible playbook describes a change you are making now. The words look similar and the guarantees are not.

Three ways to get some of it back:

Scheduled runs. Run the playbook from CI on a timer as well as on demand. Approximate convergence at a coarser granularity, and the simplest option.

ansible-pull. Each node clones the repository on a cron and applies it to itself. This is Puppet's model with Ansible's syntax, and it is closer than most people realise. You lose orchestration, because there is no central coordinator, which is a fine trade for a fleet of identical stateless machines.

Accept it, and detect instead of correct. Run the playbook in --check mode on a schedule and alert if anything reports as changed. You do not fix drift automatically, but you find out about it.

Two things that get worse, and one with no equivalent

Variable precedence. Hiera's hierarchy is one you define, in a file you can read. Ansible's precedence is fixed, has around twenty-two levels, and you simply have to learn it. Coming from Hiera this feels like a downgrade, because you can no longer answer "where did this value come from" by reading your own configuration. Keep variable sources few and boring, and prefer group_vars over the clever options.

Type checking. Puppet has a type system, and data types on class parameters are checked at compile time. Ansible has argument specs for roles, which are optional and not widely used. Most Ansible fails on a bad value at the moment it is used.

Exported resources have no equivalent. Collecting facts from many nodes and using them to build a config on another node, the classic load-balancer-knows-its-backends pattern, is a PuppetDB feature. Ansible's closest analogue is reading hostvars across the play, which only works if every relevant host is in the same run. For anything larger you end up querying a real inventory source or a service registry. Worth knowing before you plan a migration around it.

What you gain

An article that only listed losses would be misleading, so, briefly.

No agent, no CA, no certificate management. Ansible needs SSH and Python, both of which are already there. Everyone who has spent an afternoon on Puppet certificate problems knows what this is worth.

Orchestration across nodes. Rolling deployments, draining a load balancer before touching a host, health checks between batches. This is the thing Puppet's model structurally cannot express, and it is the reason many people move.

Ad-hoc commands. ansible all -m ping and you are done. There is no Puppet equivalent that does not involve Bolt.

A shorter path from reading to writing. YAML is not a language. That is a real cost in expressiveness and a real gain in how quickly someone else on your team can contribute.

What it was actually like

I started by converting manifests directly. Open the Puppet module, open a new role, translate it resource by resource. It seemed like the obvious approach. Each resource has a matching module, the structure maps onto roles, and the work is mechanical.

I ended up starting over.

The translation was not wrong in the sense of being broken. It was wrong in the sense of being Puppet written in YAML. Every one of the four differences above shows up in that output, and they compound.

Manifests are written for a graph. When you translate them statement by statement into a list, the ordering that Puppet was deriving for you is simply absent, and you do not notice until something runs in the wrong order on a machine that is not in the state you assumed. The relationships were in the manifest. They did not survive the translation, because there was nowhere for them to go.

The same applies to idempotency. A Puppet resource is idempotent because of what it is. When the direct equivalent does not exist and you reach for command, you have quietly dropped the property without noticing, because in Puppet you never had to think about it. My first version reported changes on every run. That is not just noise. It means the playbook can no longer tell you anything about drift, which is one of the few things Ansible gives you in exchange for losing convergence.

The second attempt went differently because I stopped translating. I took each Puppet module, worked out what it was actually for, and wrote the role from that. Same outcome, different shape, far less code. Several Puppet modules collapsed into a handful of tasks once I was not preserving structure that only existed to satisfy the compiler.

If you are about to do this

Practical order, based on the differences above rather than on the syntax.

Do not translate manifest by manifest. This is the one I got wrong, so I will put it first. A direct translation produces Ansible that reads like Puppet and works badly, because the relationships live in a graph that has nowhere to go. Take the intent of each module and write the role fresh. It is faster than it sounds, and the result is smaller.

Start with something you can afford to break. One role, one non-critical service, run end to end on a machine you can rebuild.

Enforce the two-run rule from day one. Second run reports zero changed, or the role is not finished. This habit is the closest thing Ansible has to Puppet's idempotency guarantee, and it is much harder to retrofit later.

Decide the convergence question early. Scheduled runs, ansible-pull, or drift detection. Choosing nothing means choosing drift, and you should at least do that deliberately.

Write down who owns which file. Puppet enforced this for you at compile time. Now it is a convention, and conventions need to be written somewhere.

Add new comment

Restricted HTML

  • Allowed HTML tags: <a href hreflang> <em> <strong> <cite> <blockquote cite> <code> <ul type> <ol start type> <li> <dl> <dt> <dd> <h2 id> <h3 id> <h4 id> <h5 id> <h6 id>
  • Lines and paragraphs break automatically.
  • Web page addresses and email addresses turn into links automatically.