Skip to content
Stribog

Operations

All writing

Puppet vs Ansible: Continuous Enforcement or Push Execution

Puppet's agent re-applies desired state on a timer; Ansible changes a host only when something runs it. How each model shapes drift, audit evidence and exit.

Stribog13 min read

Most comparisons of the two tools stop at the surface: a Ruby-flavoured declarative language against YAML playbooks, an agent against SSH, a steeper learning curve against a gentler one. Those differences are real and mostly irrelevant to the question an infrastructure lead actually has to answer, which is what happens to a server between the moments someone looks at it. One tool keeps looking on its own. The other looks when asked. That single property decides how drift behaves, what an auditor can be shown, what breaks at three in the morning and what it costs to leave — and since late 2024, "Puppet" itself has split into a vendor build and a community fork, which changes the exit arithmetic.

Two models, one word: what configuration management means in each

In the Puppet model — described here through OpenVox, the community fork whose source and defaults are public — a resident agent on every node asks a server for its configuration. The OpenVox catalog documentation puts it plainly: the agent uses a document called a catalog, which it downloads from an OpenVox Server, and the catalog describes the desired state for each resource that should be managed. The agent sends facts, the server compiles the code for that node into a catalog, the agent applies it locally and reports back.

Ansible inverts that. Its project README states the design goal: avoid custom agents and additional open ports, be agentless by leveraging the existing SSH daemon, and manage new machines without bootstrapping any software. A controller holds the inventory and the playbooks, connects to targets over SSH, runs modules there and leaves. No process on the managed host remembers the desired state once the run ends.

ansible-pull sits between the two. According to its own CLI source, it pulls playbooks from a VCS repository and executes them on the target host, inverting the default push architecture into a pull one. It is still an executor rather than an agent — nothing runs between invocations unless you schedule it — but it is the piece that lets Ansible imitate a timer when you want one.

The same desired state, two ownership models. The agent loop on the left runs whether anyone is watching; the right side runs when something invokes it.

Drift is a property of the enforcement model

Configuration drift is usually discussed as a discipline problem — someone edited a file by hand, someone ran a hotfix and forgot to commit it. That framing misses the mechanism. Whether a hand edit survives depends on what runs next, and when.

OpenVox's defaults.rb sets runinterval to 30 minutes and describes it as how often the agent applies the catalog, with a note worth reading twice: a runinterval of 0 means run continuously, not never run. So a hand edit to a managed resource on an OpenVox node is reverted at the next run, while agent and server are healthy. The drift window is bounded by configuration you can read in one file.

Three neighbouring settings shape that guarantee:

  • splay is off by default. When enabled, the agent sleeps for a random time — from immediately up to splaylimit, which defaults to the runinterval — before its first run after a service restart. It spreads a fleet that restarted together; it does not change the interval itself.
  • runtimeout defaults to one hour: an OpenVox agent run that exceeds it is aborted. A run that hangs does not hold the node out of enforcement indefinitely.
  • usecacheonfailure defaults to true: when the remote configuration will not compile, the agent uses its cached configuration. This is availability over freshness. A syntax error merged to the control repository does not leave nodes unmanaged — they keep enforcing the last catalog that compiled — but it also means a node can enforce a stale state, quietly, until someone fixes the compile.

Ansible has no equivalent clock. A host managed only by ansible-playbook runs from a controller is in the desired state at the end of a successful run and in whatever state people and processes leave it afterwards. If the next run is next month's release, the drift window is a month. The consequence is that Ansible's drift story is whatever you build around it: a schedule, a check-mode sweep, a job history somebody reads.

What breaks, and who is on call

Each model moves the failure surface somewhere different. Neither removes it.

The enforcement side

  • The server is a fleet-wide control plane. Every node depends on it for catalogs. Add PuppetDB and it becomes part of that plane: the OpenVox exported-resources page notes that exported resources require PuppetDB connected to the server. Certificates, compile capacity and database health become on-call concerns.
  • Stale enforcement. As above, cached catalogs keep nodes converging on a state nobody intended any more.
  • A noop override that beats the global switch. The description of the noop setting in OpenVox's defaults.rb says that a resource declared with noop => false will be changed if necessary even when the agent runs with noop = true or --noop. A fleet-wide dry run is therefore only dry for resources that do not opt out. Grep the control repository for noop => false before trusting one.
  • The agent is software on every node. It needs packaging, upgrades and a trusted source for both — which is exactly where the 2024 licensing change bites.

The push side

  • The controller is a single executor. Whoever holds the controller, its inventory and its credentials holds the fleet.
  • Parallelism is a setting. Ansible's forks defaults to 5, the maximum number of parallel processes used to execute tasks on target hosts. Large fleets tune it; runs against thousands of hosts are a capacity question for the controller.
  • Reachability is a precondition. A host the controller cannot reach over SSH is simply not managed on that run.
  • The run that never happens. No run, no correction, no evidence — and no alert, unless you built one for absence.
  • Check mode can be silent. The check-mode guide is explicit: modules that do not support check mode report nothing and do nothing. A clean --check is a statement about the modules that could answer.

The idempotency side of that last point — changed_when, modules that always report change, what convergence actually guarantees — is covered in Terraform vs Ansible: state you own, state you converge and is not repeated here.

The audit trail: proving state, not just intent

An auditor asking about configuration management wants to see that systems are in the declared state and that deviations are detected and corrected. A repository of manifests or playbooks proves intent. The evidence of state comes from runs.

The OpenVox report format is unusually good evidence. Every run report carries a status of failed, changed or unchanged, a noop_pending flag for runs that declined to apply changes because of no-op mode, and — the field that matters most here — corrective_change. Per resource, it records whether a change or no-op event corrected unexpected drift; at run level, whether any event in the run was a corrective change. That separates a newly intended state from drift that something introduced behind the tool's back and the agent put right — the distinction a drift control is supposed to evidence, emitted every run without extra tooling.

Ansible's native evidence is the play recap — ok, changed, unreachable, failed and skipped counts per host — plus whatever output, callback plugin or job history you retain. --diff can be used alone or with --check, and modules that support diff mode report the changes made or, with --check, the changes that would have been made. That is a good drift detector when you run it on a schedule and keep the results. It does not distinguish corrective from intended change on its own; that distinction lives in your process.

The OpenVox 9 trap: reports stop being kept

According to the OpenVox release notes, OpenVox 9 changed the default of the reports setting from store to none, making report processing opt-in; the change arrived in 9.0.0-beta2, and as of October 2026 the current release is 9.1.0, released on 13 October 2026. reports is the list of report handlers; defaults.rb scopes it to the server and to puppet apply — the primary server calls those handlers with the reports agents send it. A server that should keep writing YAML reports to its report directory needs reports = store set explicitly. Servers that already set reports explicitly, such as puppetdb, are not affected. This is an OpenVox change; it should not be assumed of Puppet Core.

The agent side needs nothing: report defaults to true, so agents keep sending reports after every transaction. Setting it explicitly documents intent but does not restore storage:

ini
# /etc/puppetlabs/puppet/puppet.conf on each agent node
[agent]
server = openvox.example.com
runinterval = 30m
splay = true
report = true
Agent side. `report = true` is already the default; this file does not bring back report storage after an OpenVox 9 upgrade.
ini
# /etc/puppetlabs/puppet/puppet.conf on the primary OpenVox server
[server]
reports = store
Server side — the actual upgrade fix. Add `puppetdb` or `http` to the comma-separated list if the site uses them. Setting it in `[main]` also works on the primary; for `puppet apply` fleets, set it on the node running apply.

Configuration in practice: the same intent, both ways

One small desired state: root login and password authentication disabled in sshd, the daemon running and restarted when the setting changes. Both versions manage a whole drop-in file and validate it with sshd -t before it replaces the live one. They assume a host whose sshd_config includes sshd_config.d/*.conf and whose service is named sshd; adjust both for your distribution.

puppet
class ssh_hardening {
  package { 'openssh-server':
    ensure => installed,
  }

  file { '/etc/ssh/sshd_config.d/10-hardening.conf':
    ensure       => file,
    owner        => 'root',
    group        => 'root',
    mode         => '0600',
    content      => "PermitRootLogin no\nPasswordAuthentication no\n",
    validate_cmd => '/usr/sbin/sshd -t -f %',
    require      => Package['openssh-server'],
    notify       => Service['sshd'],
  }

  service { 'sshd':
    ensure => running,
    enable => true,
  }
}
OpenVox / Puppet. The agent re-checks every resource here on each run; a hand edit to the drop-in is reverted and reported as a corrective change.
yaml
- name: Enforce SSH hardening
  hosts: all
  become: true
  tasks:
    - name: Install openssh-server
      ansible.builtin.package:
        name: openssh-server
        state: present

    - name: Write the hardening drop-in
      ansible.builtin.copy:
        dest: /etc/ssh/sshd_config.d/10-hardening.conf
        content: |
          PermitRootLogin no
          PasswordAuthentication no
        owner: root
        group: root
        mode: "0600"
        validate: /usr/sbin/sshd -t -f %s
      notify: Restart sshd

    - name: Keep sshd running and enabled
      ansible.builtin.service:
        name: sshd
        state: started
        enabled: true

  handlers:
    - name: Restart sshd
      ansible.builtin.service:
        name: sshd
        state: restarted
Ansible, site.yml. Identical intent; it is enforced only when this playbook runs.

To turn the playbook into periodic remediation, schedule ansible-pull on each host. Its help text describes exactly this use — tuning the frequency for extreme scale-out and periodic remediation — and warns that the CLI tools are not designed to run concurrently with themselves, recommending an external scheduler or locking. A systemd timer driving a Type=oneshot service satisfies that: systemd does not start a second instance of a service that is still active.

ini
# /etc/systemd/system/ansible-pull.service
[Unit]
Description=Converge this host from the infrastructure repository
Wants=network-online.target
After=network-online.target

[Service]
Type=oneshot
ExecStart=/usr/bin/ansible-pull --url https://git.example.com/infra/ansible.git --checkout main --directory /var/lib/ansible-pull site.yml
The service. Adjust the `ansible-pull` path to where your packaging installs it.
ini
# /etc/systemd/system/ansible-pull.timer
[Unit]
Description=Run ansible-pull every 30 minutes

[Timer]
OnBootSec=5min
OnUnitActiveSec=30min
RandomizedDelaySec=5min

[Install]
WantedBy=timers.target
The timer: Ansible's opt-in equivalent of runinterval and splay. Enable with `systemctl enable --now ansible-pull.timer`.

Leave out --only-if-changed here. That flag runs the playbook only if the repository has been updated, which is the opposite of remediation: a host edited by hand while the repository stood still would never be corrected. Even with the timer, the gap narrows rather than closes — there is no server-compiled catalog and no corrective_change flag, only a log per host you have to collect.

Drift checks, both ways, plus a count of corrective changes from the agent's last run report. The store handler and the agent's lastrunreport both write Ruby-tagged YAML, so the script converts it to JSON with PyYAML before handing it to jq:

bash
# OpenVox / Puppet: compile, compare and report, changing nothing
# (except resources pinned with noop => false).
sudo puppet agent --test --noop

# Ansible: report what would change, with diffs where modules support it.
ansible-playbook -i inventory/production site.yml --check --diff

# Corrective changes in this node's last run report: run-level flag
# plus the resource_statuses entries marked corrective.
report=$(sudo puppet config print lastrunreport --section agent)
sudo cat "$report" | python3 -c '
import json, sys, yaml

class Loader(yaml.SafeLoader):
    pass

def untagged(loader, suffix, node):
    if isinstance(node, yaml.MappingNode):
        return loader.construct_mapping(node, deep=True)
    if isinstance(node, yaml.SequenceNode):
        return loader.construct_sequence(node, deep=True)
    return loader.construct_scalar(node)

Loader.add_multi_constructor("!", untagged)
json.dump(yaml.load(sys.stdin, Loader=Loader), sys.stdout, default=str)
' | jq '{run: .corrective_change,
        resources: [.resource_statuses | to_entries[]
                    | select(.value.corrective_change) | .key]}'
Requires python3 with PyYAML, and jq. Keys in `resource_statuses` are resource names in Type[title] form.

Licensing and packaging in 2026: Puppet Core, OpenVox, AWX

On 7 November 2024, Perforce announced that from early 2025 it would ship new Puppet binaries and packages to a private, hardened and controlled location, with community contributors given free access under an End-User License Agreement for development use. The announced EULA allowed free access to the hardened releases up to 25 nodes, with larger capacities requiring a commercial licence, while the source code stayed available under Apache 2.0. Those are the terms as announced then; check current terms with Perforce rather than relying on them.

The community response was OpenVox, under Vox Pupuli. It started as a Puppet package mirror by Overlook InfraTech to keep community packages flowing when Perforce discontinued public packaging in late 2024, and its maintainers call it a soft fork because they intend to maintain downstream compatibility for as long as they are able. It now has its own release line — the OpenVox 9 reports change above is an example of the two lines beginning to diverge in defaults.

On the Ansible side, ansible-core is licensed GPL-3.0-or-later; as of October 2026 the latest stable release on GitHub is 2.21.5, with 2.22 at release-candidate stage. The AWX README states that its last release was on 2 July 2024 and that releases are paused during a large-scale refactoring. That is not abandonment, but a team planning to run AWX as its Ansible control plane should price in a paused upstream.

The pattern is familiar from the OpenTofu migration: when the binaries you run come from one vendor's private channel, the licence on the source is necessary but not sufficient. What matters is who builds what you install, and whether you could build it yourself. For air-gapped estates, the same question extends to where agent packages are mirrored — the subject of air-gapped patching with Katello.

Exit ramps: migrating between enforcement and push

Migrations between these tools fail when they are treated as translations. The code is the easy part. The guarantee is the hard part. In either direction:

  1. Inventory what is actually managed. From the old tool's reports or code, list every resource type and title under management, and which ones change often. That list is the migration scope; anything not on it is not managed today either.
  2. Write the new code against the same hosts in dry-run. Run new Ansible plays with --check --diff, or new manifests with --noop, until they report no changes on hosts the old tool already converges. Remember the silent cases: check-mode modules that report nothing, and resources pinned with noop => false.
  3. Keep the old tool as a drift oracle during cutover. Switch the old tool to dry-run — noop = true on the agents, or --check on the playbooks — rather than removing it. It keeps reporting divergence from the old intent while the new tool takes over enforcement, which catches resources the new code forgot.
  4. Replace the timer, or retire it on purpose. Moving from OpenVox to Ansible, decide explicitly whether to restore periodic enforcement with an ansible-pull timer or a scheduled controller job. Moving from Ansible to OpenVox, decide the runinterval and alerting for compile failures before agents go live.
  5. Keep the evidence continuous. Archive the old tool's reports and job logs for the retention period your auditor expects, and start retaining the new tool's before the old one stops — on OpenVox 9, that means setting reports on the server first.

Moving between Puppet Core and OpenVox is a smaller step, because OpenVox intends downstream compatibility — but defaults now differ, so read the release notes of the line you land on rather than assuming parity.

Choosing for the long game

Make the decision on operator axes, not on syntax:

  • Who owns the control plane? OpenVox means a server, certificates and often PuppetDB, run like any other tier-one service. Ansible means a controller and its credentials — smaller, but every byte of your fleet's authority sits there.
  • What must the auditor see? If the control asks for continuous enforcement with evidence of corrected drift, the agent model emits it every run. Ansible can approach it with scheduled check runs and retained logs, but you build and evidence that machinery yourself. The ISO 27001 controls you operate yourself are where that difference lands.
  • How mutable are the hosts? On immutable platforms such as Talos Linux, there is little left for either tool to converge; on long-lived general-purpose servers, the enforcement model matters most.
  • Whose binaries do you depend on? Price the Perforce channel, the OpenVox community line and a paused AWX upstream as dependencies with their own exit costs.

Neither tool wins in general. If a host should never be more than one interval away from its declared state, and you can own the server that guarantees it, the agent model is the honest choice. If change should happen only when a human or a pipeline decides, and you are willing to build the drift detection explicitly, the push model is. What does not work is choosing Ansible and assuming Puppet's guarantees came with it.

§FAQ/Common questions

Frequently asked

What is the main difference between Puppet and Ansible?

The enforcement model. A Puppet or OpenVox agent on each node fetches a server-compiled catalog and re-applies it on a timer — every 30 minutes by default in OpenVox — so drift is corrected without anyone asking. Ansible is agentless: a controller pushes changes over SSH only when a person, a CI job or a scheduled ansible-pull runs it, so drift lasts until the next run.

Can Ansible enforce configuration continuously like Puppet?

Partly. Running ansible-pull from a systemd timer or cron gives periodic remediation, and ansible-pull's own help text describes that use. It narrows the gap but does not close it: there is no server-compiled catalog, no cached-catalog fallback and no corrective_change flag, and you collect the evidence yourself. Leave out --only-if-changed, which skips runs when the repository has not changed.

What changed with reports in OpenVox 9?

OpenVox 9 changed the default of the server-side reports setting from store to none, so report processing is opt-in. Agents still send reports, because report defaults to true, but an upgraded server stops storing them unless reports is set explicitly, for example reports = store. Servers that already set reports explicitly, such as to puppetdb, are not affected.

Is Puppet still open source?

The source remains under Apache 2.0, but on 7 November 2024 Perforce announced that new Puppet binaries and packages would ship from a private, hardened location under a EULA, free up to 25 nodes as announced then. OpenVox, a Vox Pupuli soft fork, continues community-built packages under Apache 2.0 and aims to stay downstream-compatible.

puppet vs ansibleansible vs puppetpuppet agent runinterval drift correctionopenvox puppet forkansible-pull periodic remediationpuppet corrective_change report

Executive Briefing

Thirty minutes to clarify your infrastructure risk

Walk us through your vendor footprint and regulatory constraints. We will tell you honestly where sovereignty creates leverage — and where it does not. No pitch deck. No obligation.