Puppet
Puppet configures machines the way a thermostat controls a room: you declare the target temperature once, and something keeps checking, on its own schedule, whether reality still matches it. A persistent agent runs on every managed node, phones home to a central Puppet Server on a fixed interval, and pulls down a catalog — a fully resolved, node-specific list of resources compiled from your manifests — then applies only whatever differs from what's already there. This page covers that compile-and-apply cycle end to end, the Puppet language you actually write manifests in, the day-to-day commands, the failure modes that bite hardest at fleet scale, and why this pull-based, enforced-convergence model is a genuinely different tool for a genuinely different job than Ansible's push model — not just a syntax preference.
Imagine a robot groundskeeper assigned to one very large garden. Every thirty minutes, without being asked, it walks its rounds: check this hedge's height against the blueprint, check that sprinkler's schedule, check whether a weed snuck in since the last pass. It doesn't wait for a phone call — it already knows what "correct" looks like, and it goes looking for the gap on its own clock, forever. A gardener you call in only when you remember to (that's the tool on the next page) does great work too — it just won't notice a fence blew open at 3 a.m. until someone tells it to go look.
What Puppet is and the problem it solves
☺ Like you're 10: Puppet is a robot that never stops walking its rounds — you write down what the garden should look like once, and it keeps checking forever, without anyone remembering to ask.
Puppet was created by Luke Kanies in 2005, making it one of the first tools to bring a declarative, desired-state model to server configuration — it predates Chef by two years and Ansible by roughly seven. Its core idea has stayed constant across two decades: you don't write a script that installs nginx, you write a statement that nginx should be installed, and a persistent agent on every managed node keeps that statement true, continuously, without anyone re-running anything by hand.
The problem this solves is one every large, long-lived fleet eventually hits: hand configuration and even one-off scripted configuration do not survive contact with reality. Someone SSHes in during an incident and hand-patches a config file. A cron job gets added by a departed engineer and never makes it into any repo. A junior hire "just quickly" bumps a worker-process count on one box to fight a fire. None of that shows up anywhere until an audit, an outage, or a compliance scan finds it — by which point the fleet has quietly diverged into a few thousand slightly different snowflakes. Puppet's answer is to make convergence a property of the system itself rather than a discipline someone has to remember to practice: write the desired state once, and every agent keeps reasserting it on its own schedule, whether or not anyone is watching.
Puppet Inc. was acquired by Perforce in 2022, and the open-source ecosystem around Puppet has been in visible motion since — including community-fork activity in response to licensing and investment changes on some components. None of that changes the mechanics on this page, but it's exactly the kind of ownership detail that shifts after a course is written; verify current licensing, product tiers, and roadmap directly on Puppet's (and Perforce's) own pages before you commit a team to a specific edition.
Architecture: the compile-and-apply cycle
☺ Like you're 10: Every visit follows the same steps: the agent says "here's what I am," the server says "here's what you should look like," the agent makes it so, and then it reports back what it did.
Four pieces make up a standard Puppet deployment. The agent is a daemon installed on every managed node; it never receives instructions pushed at it, it only ever initiates. Puppet Server is the JVM-based service every agent talks to — the modern replacement for the older Ruby/Rack "puppet master" run behind Passenger, which you'll still see referenced in older material. PuppetDB, backed by PostgreSQL, stores every submitted fact, compiled catalog, and run report, and is what makes exported-resource collection and ad hoc puppet query lookups possible. And a built-in certificate authority issues the client certificates that authenticate every agent-to-server exchange — Puppet does not trust a node just because it can reach the network.
The cycle itself, repeated on an interval:
- Gather facts. Facter, Puppet's fact-gathering tool, collects hardware, OS, network, and any custom facts you've defined, and the agent submits them to Puppet Server over HTTPS, authenticated by its client certificate.
- Compile the catalog. Puppet Server evaluates your manifests — starting at
site.pp, which classifies the node by name, a regex, or an external node classifier (ENC) — resolves every Hiera data lookup, and produces a catalog: a fully resolved, per-node JSON graph of concrete resources and their ordering relationships. The catalog is the compiled output, not the manifest text; two very different nodes can compile wildly different catalogs from the exact same source classes. - Apply. The agent walks the catalog's resource graph and, for each resource, compares current system state against declared state — taking action only on the resources that actually differ. A run against a fully-converged node changes nothing and reports zero corrections; that's the idempotency property working as intended.
- Report. The agent submits a report of what it found and changed back to Puppet Server, which forwards it — along with the facts and catalog from this run — into PuppetDB.
There's also a masterless mode: puppet apply compiles and applies a manifest locally in a single process, with no server, no PKI, and no PuppetDB involved at all. It's the standard way to test a manifest on a throwaway box, and some shops run genuinely masterless fleets at scale, distributing manifests via a git pull plus a cron-triggered puppet apply instead of the agent/server architecture above — trading the operational cost of running Puppet Server for the operational cost of building your own distribution mechanism.
The manifests you actually write
☺ Like you're 10: Three ideas do almost all the work: a resource is one fact about the machine, a class bundles resources into a reusable unit, and Hiera keeps the actual numbers out of the code so the code doesn't change every time a value does.
The smallest unit in the Puppet language is a resource — a typed declaration of one thing that should be true about the machine: a package installed, a file with specific content, a service in a specific running state.
package { 'nginx':
ensure => installed,
}
file { '/etc/nginx/nginx.conf':
ensure => file,
owner => 'root',
mode => '0644',
}
service { 'nginx':
ensure => running,
enable => true,
}On its own that snippet declares three independent facts with no relationship between them — nothing yet says "install nginx before writing its config" or "restart nginx if the config changes." A real module bundles resources into a class (a singleton — declared once per node, via include or resource-like class { } syntax) or a defined type (a reusable template you can instantiate many times under different titles, the Puppet equivalent of a function). Modules follow a fixed directory layout Puppet discovers by convention:
nginx/
├── manifests/
│ ├── init.pp # class nginx { } — the module's main class, autoloaded from the module name
│ └── vhost.pp # define nginx::vhost { } — a reusable, instantiable block
├── templates/
│ ├── nginx.conf.epp # EPP — Puppet's native templating language
│ └── vhost.conf.epp
├── data/
│ └── common.yaml # Hiera data scoped to this module
├── hiera.yaml # this module's data hierarchy
└── metadata.json # name, version, dependencies — required to publish to the ForgeA class with parameters, using the chaining arrows to make ordering explicit rather than relying on manifest order:
# modules/nginx/manifests/init.pp
class nginx (
String $package_name = 'nginx',
Integer $worker_processes = 4,
Enum['running', 'stopped'] $service_ensure = 'running',
) {
package { $package_name:
ensure => installed,
}
file { '/etc/nginx/nginx.conf':
ensure => file,
content => epp('nginx/nginx.conf.epp', { 'worker_processes' => $worker_processes }),
owner => 'root',
mode => '0644',
}
service { 'nginx':
ensure => $service_ensure,
enable => true,
}
# -> means "before" (ordering only); ~> means "notify" (ordering, plus refresh the
# right-hand resource — restart the service — only if the left-hand one actually changed)
Package[$package_name] -> File['/etc/nginx/nginx.conf'] ~> Service['nginx']
}The EPP template referenced above is Puppet's own templating language — ERB is still supported for legacy modules, but EPP is the modern default and, unlike ERB, declares its expected parameters up front:
<%- | Integer $worker_processes | -%>
worker_processes <%= $worker_processes %>;
events { worker_connections 1024; }
http {
include /etc/nginx/sites-enabled/*.conf;
}A defined type is what you reach for the moment you need more than one of something — Puppet refuses to let you declare the same resource title twice, so a second nginx virtual host can't just be a second file resource with the same name:
# modules/nginx/manifests/vhost.pp
define nginx::vhost (
Integer $port = 80,
String $document_root = "/var/www/${title}",
) {
file { "/etc/nginx/sites-available/${title}.conf":
ensure => file,
content => epp('nginx/vhost.conf.epp', { 'port' => $port, 'root' => $document_root, 'name' => $title }),
notify => Service['nginx'],
}
file { "/etc/nginx/sites-enabled/${title}.conf":
ensure => link,
target => "/etc/nginx/sites-available/${title}.conf",
notify => Service['nginx'],
}
}
# usage, anywhere the module is included:
nginx::vhost { 'acme-app': port => 8080 }
nginx::vhost { 'acme-admin': port => 8081 }Node classification happens in site.pp, the manifest Puppet Server always compiles first:
# manifests/site.pp
node default {
include profile::base
}
node /^web\d+\.acme\.internal$/ {
include nginx
}Every value in the examples above could have been hard-coded, but that's how a manifest ends up rewritten every time a number changes. Hiera is Puppet's built-in hierarchical key/value lookup, and its most useful trick is automatic parameter lookup: because class nginx declares a $worker_processes parameter, Puppet automatically looks up the key nginx::worker_processes in Hiera whenever the class is declared via plain include — no explicit hiera() call needed in the manifest at all.
# hiera.yaml — environment-level hierarchy, checked top to bottom, first match wins per key
version: 5
defaults:
datadir: data
data_hash: yaml_data
hierarchy:
- name: "Per-node overrides"
path: "nodes/%{trusted.certname}.yaml"
- name: "Per-environment"
path: "environments/%{environment}.yaml"
- name: "Common"
path: "common.yaml"# data/common.yaml — the fleet-wide default nginx::worker_processes: 4 nginx::service_ensure: running # data/nodes/web03.acme.internal.yaml — this one node needs more workers, nothing else changes nginx::worker_processes: 16
A manifest is parsed top to bottom, but that is not the same as being applied top to bottom. Without an explicit relationship — a chaining arrow, or the equivalent require / before / notify / subscribe metaparameters — two resources declared next to each other in a manifest have no guaranteed relative order at all. The manifest text describes what should exist, not when to create it; only explicit relationships or the compiled catalog's resource graph determine order.
Day-to-day commands
☺ Like you're 10: A handful of commands cover nearly everything: try it locally, run it right now instead of waiting, check the syntax, ask why a value came from where it did, and let a new machine in.
# masterless: compile and apply locally, no server involved $ puppet apply site.pp $ puppet apply --noop site.pp # dry run: show what WOULD change, touch nothing # agent/server: run now instead of waiting for the interval $ puppet agent -t # foreground, verbose, ignores the run interval $ puppet agent --test --noop # dry run against the REAL master, still no changes applied # syntax and data $ puppet parser validate manifests/init.pp # syntax check only — does not compile a catalog $ puppet lookup nginx::worker_processes --node web03.acme.internal --explain # trace WHERE a value came from # certificate authority (Puppet Server) $ puppetserver ca list --all # pending + signed certificate requests $ puppetserver ca sign --certname web09.acme.internal # modules $ puppet module install puppetlabs-stdlib # ad hoc Forge install (fine for a laptop, not a fleet) $ pdk new module acme_nginx # scaffold a module: tests, metadata.json, fixtures $ pdk validate # lint + syntax across the whole module $ pdk test unit # rspec-puppet unit tests # fleet-wide code deployment $ r10k deploy environment -p # sync every environment from the control-repo + Puppetfile
r10k (or Puppet Enterprise's built-in Code Manager) is how real fleets get manifests onto Puppet Server at all: a control repo in git holds one branch per Puppet environment plus a Puppetfile declaring exact module versions, and r10k deploy syncs both onto the server's environment directories — the closest thing Puppet has to a GitOps workflow for its own code, distinct from what it manages on target nodes.
Gotchas and failure modes
☺ Like you're 10: Most Puppet surprises come from one of three things: the manifest wasn't applied top-to-bottom the way you assumed, one bad class failed an entire node's whole run, or nobody scoped who's allowed to get a certificate.
Duplicate resource declarations are a hard compile error, not a warning. If two unrelated classes both declare user {'deploy': }, catalog compilation fails outright — Puppet enforces exactly one authoritative declaration per resource title across the entire catalog. This is a real design tradeoff: it forces a single source of truth per resource, but it means two teams' modules can collide in ways a push-based tool re-running the same task twice never would.
Ordering surprises. Since Puppet 4, the ordering setting defaults to manifest — resources without an explicit relationship apply in the order they were written — which quietly papers over missing ->/~> relationships that happen to work by luck of file order. Flip ordering to title-hash (a deterministic but effectively randomized order, Puppet's older pre-4 default) on a canary node occasionally, and any manifest relying on accidental ordering breaks loudly instead of quietly.
Exec resources aren't idempotent by default. Every other core resource type checks current state before acting; exec just runs a command, every single agent run, forever — unless you guard it with unless, onlyif, or creates. An unguarded exec is the single most common way a Puppet manifest silently stops being safe to re-run.
Puppet compiles the whole catalog before applying any of it. A single syntax error, missing class, or unresolvable reference anywhere in the classes assigned to a node fails catalog compilation entirely — meaning none of that node's resources get touched on that run, not even the ones with no relationship to the broken class. Contrast this with a push tool re-running task by task: a failure there typically halts that host's remaining tasks but leaves whatever already succeeded in place. A typo in one module can leave an entire node un-reconciled until the next successful compile.
Certificate signing is a real access-control gate, and autosign can undermine it. A new agent generates a CSR that a human — or an autosign policy on Puppet Server — must approve before it ever receives a catalog. Autosigning every request that arrives is a common shortcut, but it means anything that can reach the master's network gets a trusted certificate, including a box you didn't provision. Scope autosign to a genuine provisioning secret (a policy-based autosign.conf script, not a blanket *), or sign by hand.
Splay and the thundering herd. A fleet provisioned all at once and left on the default 30-minute interval will hit Puppet Server in the same narrow window, forever, unless splay = true jitters each agent's start time within the interval — worth setting deliberately rather than discovering the need for it during a capacity incident.
Exported resources have a two-phase timing subtlety. A resource declared with @@ (exported) on one node is written to PuppetDB but not applied there; another node collects it with a <<| |>> query — the classic pattern for a web fleet exporting its own SSH host keys and a bastion collecting Sshkey <<| |>>. The catch: the exporting node must actually run, and successfully report to PuppetDB, before the collecting node's next compile will find anything — so a freshly built fleet can converge with an empty collection on its first pass and only fill in correctly a run or two later.
On a throwaway VM, puppet apply --noop the nginx class above and read the dry-run output line by line before applying for real. Then hand-edit /etc/nginx/nginx.conf to something different and run puppet agent -t (or puppet apply again) — watch it get reverted without you asking. Break the ordering on purpose: delete the ->/~> line, change worker_processes in Hiera, and confirm the service still restarts only because of the (now-missing) notify relationship — or doesn't. Finally, declare the same file resource title in two different classes both included on the same node, and read the "duplicate declaration" compile error you get instead of a silent overwrite.
Puppet vs. Ansible (and Chef): pull vs. push, and when enforced convergence wins
☺ Like you're 10: Same job, three different philosophies — Puppet's robot never stops checking, Chef hands you a Ruby program that checks when it runs, Ansible only checks when you personally start it.
Configuration management already lays out the general push-versus-pull tradeoff; this is the specific case for Puppet's side of it. The mechanical difference is simple to state and easy to underrate: a Puppet agent decides for itself, on its own clock when to reconcile, while an Ansible controller only reconciles a host at the moment a human or a CI job explicitly runs a playbook against it. Everything else below follows from that one difference.
| Tool | Model | Language | Drift correction | Best when | Costs you |
|---|---|---|---|---|---|
| Puppet | Agent + server, pull (or masterless apply) | Purpose-built declarative DSL | Automatic, every run interval, with no one having to notice or trigger it | A large, relatively stable fleet of long-lived hosts where unnoticed drift is the bigger risk than deploy latency | Puppet Server + PuppetDB to run, a PKI to manage, an agent installed and kept alive on every node, all-or-nothing catalog compilation |
| Chef | Agent + server, pull (chef-client on a periodic interval) | Real Ruby — recipes are executable Ruby programs using a resource DSL | Automatic, same interval-based model as Puppet | A team that wants Ruby's full expressive power, not a constrained DSL, and is comfortable with that power's blast radius | Similar infra overhead to Puppet, plus real Ruby competence to use it safely |
| Ansible | Agentless, push (SSH/WinRM), triggered explicitly | YAML playbooks composed of idempotent modules | None automatic — only when something schedules and runs a check | Ad hoc orchestration, cloud-native or ephemeral infrastructure, targets you don't want a persistent agent installed on at all | Drift is invisible between runs unless you build a scheduled check yourself; SSH fan-out is slower than agents reconciling in parallel on their own |
The practical rule: reach for Puppet (or Chef) when you own a large, comparatively stable fleet of long-lived machines — bare metal, traditional VMs, an on-prem datacenter — where continuous, unattended drift correction is worth the fixed cost of running Puppet Server, PuppetDB, and a PKI, plus an agent on every box. Banks, telcos, universities, and hosting providers running thousands of long-lived hosts are the classic Puppet shops for exactly this reason. Reach for Ansible when infrastructure is ephemeral, heterogeneous, or cloud-native — autoscaling groups, network devices, one-off provisioning triggered straight from CI — where installing and maintaining a persistent agent on every target is itself overhead you don't want, and "converge only when I say so, from my own pipeline" is a feature rather than a gap.
Plenty of real shops don't pick exactly one: Ansible bootstraps a box once from bare metal or a cloud API, then hands long-term enforcement to a Puppet agent it installed as one of its own tasks. And the broader industry trend worth naming honestly is that immutable infrastructure and golden images replace long-lived, continuously-reconciled hosts with disposable ones entirely in a growing share of cloud-native shops — which doesn't make Puppet wrong, it makes "does this fleet live long enough to need enforced convergence" the actual question to ask before reaching for it. See Configuration Management & IaC for where this decision sits in the bigger picture, Compliance as Code & Policy Enforcement for how Puppet's enforced-convergence model gets leaned on for continuous compliance specifically, and Puppet Certified Professional if you want to validate this specifically.
Recon: Agent check-in received. Facts submitted. Catalog compiled. Applying now — three resources unchanged, one needs correction.
Foxy: Correction? Nobody touched that box.
Recon: Someone did. /etc/nginx/nginx.conf drifted from the compiled catalog forty minutes ago. I don't ask who — I only reconcile, every thirty minutes, whether anyone's watching or not.
Benny the Beaver: That's the tradeoff, though — I had to stand up Puppet Server and get every node's certificate signed before any of this worked at all. Ansible would've just SSH'd in and been done in five minutes.
Gizmo: So just turn on autosign for everything. One line in autosign.conf, every node gets a cert automatically, no more waiting around. 🤑
Timmy the Turtle: Autosign-everything means any box that can reach the master gets a trusted certificate — including one an attacker stood up. Sign requests by hand, or scope autosign to a real provisioning secret.
Recon: Agreed. Trust is not a convenience setting.
Foxy: So Puppet's the one that keeps checking forever, and Ansible's the one that only checks when you personally ask it to.
Recon: Correct. Pick the model that matches how long your fleet actually lives.
1. Name the four steps of Puppet's compile-and-apply cycle, and say which side — agent or server — performs the catalog compilation. 2. What is a "catalog," and how is it different from the manifest that produced it? 3. Explain the difference between the -> and ~> chaining arrows. 4. Why does a single broken class fail an entire node's catalog compilation, and what's the practical consequence of that blast radius? 5. Contrast Puppet's pull model with Ansible's push model — which one corrects drift automatically, and what infrastructure does that cost you that Ansible doesn't need? 6. What's the risk of an overly permissive autosign policy? 7. Give one situation where you'd reach for an exported resource instead of an ordinary resource declaration.
Check your answers
- 1) The agent gathers facts via Facter and submits them to Puppet Server. 2) Puppet Server compiles a catalog from manifests, Hiera data, and those facts. 3) The agent applies the catalog, correcting only resources that differ from current state. 4) The agent submits a report back, which the server forwards into PuppetDB. The server compiles the catalog, not the agent.
- A catalog is the fully resolved, node-specific JSON graph of concrete resources and their ordering relationships, compiled for one specific node from the manifest classes assigned to it plus its facts and Hiera data. The manifest is source code; the catalog is that source code's compiled output for exactly one node — two different nodes can compile very different catalogs from identical manifest source.
->("before") only enforces ordering — the left resource is applied before the right one.~>("notify") enforces the same ordering and triggers a refresh (e.g. a service restart) on the right-hand resource, but only if the left-hand resource actually changed on that run.- Puppet compiles the entire catalog before applying any of it, so one syntax error or unresolvable reference anywhere in a node's assigned classes fails compilation entirely. The consequence: none of that node's resources are touched on that run at all, even ones completely unrelated to the broken class, leaving the whole node un-reconciled until the next successful compile.
- Puppet's agent reconciles automatically on its own schedule (drift gets corrected without anyone noticing or triggering anything); Ansible only reconciles when a human or CI job explicitly runs a playbook, so drift between runs is invisible unless something is built to check for it. Puppet's automatic correction costs you Puppet Server, PuppetDB, a PKI to manage, and a persistent agent installed and kept alive on every node — none of which Ansible requires.
- Autosigning every certificate request means any machine that can reach the Puppet Server's network gets a trusted certificate and, with it, the ability to receive (and in some setups, submit) catalogs — including a box you never provisioned, such as one an attacker stood up. Scope autosign to a real provisioning secret or sign requests by hand instead.
- Any peer-discovery pattern where one node doesn't know in advance which other nodes will need its data — e.g. every web server in a fleet exporting its own SSH host key with
@@sshkey, and a bastion host collecting all of them withSshkey <<| |>>without either side's manifest naming the other explicitly.