CoursesPuppetWhat Puppet is & the model

What Puppet is & the model

Declarative, agent/master, model-driven.

Intermediate12 min · lesson 1 of 12

A thermostat never takes an order like "run the boiler for twelve minutes". You give it a number. It reads the room, compares the reading to the number, acts on the difference, then does the same thing again a minute later. Leave a window open and it fights the window, quietly, forever. Puppet is that idea pointed at a server. You write down what the machine should look like. A program on the machine reads reality, compares it to your description, repairs the gap, then wakes up half an hour later and checks again.

That style of writing has a name: declarative (you state the finished condition and never the steps to reach it). The unit you state it in is a resource, meaning one managed thing on the box, with a type, a name and some attributes. A package that must be installed. A file holding exactly these bytes, owned by root, readable by nobody else. A service running now and set to start at boot. You never write apt-get install or systemctl enable. You describe the end state and Puppet picks the verbs. Every resource gets the same treatment: look at the machine, compare it to the description, change only what disagrees. Run the lot twice and the second run does nothing. That property is idempotence (running it again changes nothing, because nothing is left to change).

Model-driven is the other label people hang on Puppet, and it means something exact. Your code is not a program that runs top to bottom. It is a description that gets compiled into a catalog: a document listing every resource this one machine should have, plus the order they depend on each other in, finished before a single byte of disk is touched. Think of a prescription. It exists on paper before anyone swallows anything, so you can read it, argue with it, refuse to fill it, or keep a copy. Because a catalog is a document rather than a script, Puppet can show you what it would do without doing it, print a before and after, or ship the whole thing to a report. A shell script cannot make that offer. It tells you what happened, after it happened, on the machine where it went wrong.

Puppet 8 hands you a small set of commands. puppet apply compiles and applies a catalog right here on one machine, with no server involved. puppet agent does the same work on a schedule against a central Puppet Server. puppet resource reads live machine state back out and prints it in Puppet's own language. Around those sit Facter (the inventory program that measures the machine), Hiera 5 (a stack of plain data files your code looks values up in, so the same code can serve staging and production), PuppetDB (a database holding every node's facts and reports), and the PDK, or Puppet Development Kit, which scaffolds modules and runs their tests. One naming note before you install anything. Perforce, who own Puppet, announced in 2024 that development would move into a private repository and that official Puppet 8 builds would ship under a new licence, which took effect through 2025. Vox Pupuli, the community group behind a lot of the modules, forked the last openly developed code as OpenVox. Same language, same commands, same modules from the Forge (Puppet's public module registry), packages named openvox-agent and openvox-server instead of puppet-agent and puppetserver.

Facts up, catalog down

A bespoke tailor does not post out finished suits. You send measurements, the tailor cuts cloth that fits one body, and that suit comes back to you and nobody else. The classic Puppet setup has the same shape. Every managed machine runs an agent that wakes on a timer. It measures itself with Facter (operating system, version, network addresses, memory, disks, whether it is a virtual machine, several hundred values in all), sends those facts to the Puppet Server, and gets back a catalog compiled from your code plus those facts, for that node alone. It applies the catalog locally, as root, then posts back a report of what changed, what failed, and how long each resource took.

Direction of travel matters. The agent dials out over HTTPS (HyperText Transfer Protocol Secure, the encrypted web protocol your browser speaks) on port 8140. Nothing dials in. No inbound firewall rule pointing at your database hosts, no extra listening port on a managed box for a stranger to knock on. Two prices come with that. Every node holds a client certificate entitling it to ask for its own catalog, and the server holds the code that becomes root's to-do list on every machine you own. Whoever can merge a commit into the control repository (the git repository holding your Puppet code) gets root across the fleet within one run interval. Code review on that repo is a production access control, not a style preference.

One agent run, start to finish
1Agent wakes
on a timer, every 1800 seconds by default
2Facts go up
Facter's measurements of this node, over HTTPS on port 8140
3Catalog comes down
your code plus those facts, compiled for this one node
4Apply in graph order
each resource inspects reality first, changes only what disagrees
5Report goes back
changes, failures and timings, stored in PuppetDB
6Exit code
0 clean, 2 changed, 4 failed, 6 both, then sleep and repeat
Anything that drifts between two runs, meaning anything changed on the box without going through your code, gets undone at the next one. Your run interval is the longest an unauthorised change to a modelled resource can survive.
terminal
$ sudo puppet config print --section agent environment runinterval splay splaylimit
output
environment = production
runinterval = 1800
splay = false
splaylimit = 1800

Thirty minutes between runs, so each node checks in twice an hour. The --section agent part earns its keep, because those settings normally live under [agent] in /etc/puppetlabs/puppet/puppet.conf, and printing the default main section can show you a value nobody is actually using. Splay is off out of the box, which means a fleet that all booted together knocks on the server's door in the same second. Set splay = true under [agent] and each agent sleeps a random slice of splaylimit before it starts, spreading the load out. That interval is a security number as much as a performance one. With the defaults, an sshd_config (the config file for the SSH server) that someone edited by hand lives at most half an hour before the next run puts it back.

Writing the model

/root/demo/ssh.pp
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",
require => Package['openssh-server'],
notify => Service['ssh'],
}
service { 'ssh':
ensure => running,
enable => true,
}

Read the syntax as type { 'title': attribute => value }. The title is how the rest of your code refers to this resource, and for a file it doubles as the path on disk. ensure is the attribute nearly every type carries, answering "should this exist, and in what form": present, absent, file, directory, running, installed. The 10 on the front of the filename is deliberate. The SSH server keeps the first value it finds for each keyword, and the include line sits at the top of /etc/ssh/sshd_config, so a low number wins the argument. Number it 50 and whatever your cloud image already dropped in that directory may get there first.

Quote the mode. Left unquoted, 0600 is a number to Puppet, which converts the number to octal and lands back on 600, so it survives by luck. Write 644 without the leading zero and the same conversion turns decimal 644 into octal 1204: sticky bit set, owner write-only, world readable. Nothing warns you. Use a string, always. require and notify are metaparameters, meaning attributes about the resource itself rather than about the thing it manages. require says apply the package before me. notify says apply me before the service and, if I actually changed something, poke it afterwards. That poke is a refresh event, and it is why sshd restarts on the run where the file changed and stays untouched on the runs where it did not. The unit is called ssh on Debian and Ubuntu, sshd on Red Hat and its relatives, and the resource title has to match whichever one your box uses.

terminal
$ sudo puppet apply --noop /root/demo/ssh.pp
output
Notice: Compiled catalog for web01.acme.internal in environment production in 0.38 seconds
Notice: /Stage[main]/Main/File[/etc/ssh/sshd_config.d/10-hardening.conf]/ensure: current_value 'absent', should be 'file' (noop)
Notice: /Stage[main]/Main/Service[ssh]: Would have triggered 'refresh' from 1 event
Notice: Class[Main]: Would have triggered 'refresh' from 1 event
Notice: Stage[main]: Would have triggered 'refresh' from 1 event
Notice: Applied catalog in 0.44 seconds

--noop is short for no-operation, a dry run. Puppet compiled the catalog, walked every resource, worked out the differences and wrote nothing. Notice what it did not say. Not a word about the package, because openssh-server was already installed and already matched. The three "would have triggered" lines are one refresh event travelling up the chain that contains the service: service, then class, then stage. Get used to reading those paths. /Stage[main]/Main/File[...] is exactly how Puppet names that resource in every report and every error you will ever get about it.

terminal
$ sudo puppet apply /root/demo/ssh.pp
output
Notice: Compiled catalog for web01.acme.internal in environment production in 0.36 seconds
Notice: /Stage[main]/Main/File[/etc/ssh/sshd_config.d/10-hardening.conf]/ensure: defined content as '{md5}5a0a9b4347f8a6a919280336fbd7ebc0'
Notice: /Stage[main]/Main/Service[ssh]: Triggered 'refresh' from 1 event
Notice: Applied catalog in 1.21 seconds
terminal
# nothing touched on the box, nothing edited in the manifest
$ sudo puppet apply /root/demo/ssh.pp
output
Notice: Compiled catalog for web01.acme.internal in environment production in 0.35 seconds
Notice: Applied catalog in 0.29 seconds

Two lines. No package fetched, no file rewritten, no service bounced, because nothing disagreed with the model. That {md5} string in the run before is a checksum, a short fingerprint of the file's exact contents, which is how Puppet reports a content change without printing the file into your logs. And the silence here is the product. You can run this every half hour on nine hundred machines without it being an event, which is precisely what makes the noisy runs worth reading.

terminal
$ puppet resource service ssh
output
service { 'ssh':
ensure => 'running',
enable => 'true',
provider => 'systemd',
}

That is the model running backwards. puppet resource inspects the live machine and prints what it finds in manifest language, which makes it the fastest way to learn attribute names and to see what a box really looks like right now. You wrote ensure => running. Puppet reports provider => 'systemd', the implementation it picked on this host, systemd being the program that starts and stops services on most Linux distributions today. That layer of translation between "running" and whatever this operating system calls starting a service has a name, the resource abstraction layer, and it gets the next lesson.

puppet resource is not a read-only command
Reading state with puppet resource user deploy is safe, and it is the right tool for the job. Add an attribute and the same command writes, immediately: puppet resource user deploy ensure=absent deletes that account there and then. No manifest, no catalog, no review, no diff, and no report goes to PuppetDB, so a change to production leaves no trace in the place your team looks for changes. Second trap on the same command. Run as root on Linux, puppet resource user root prints the account's password hash straight out of /etc/shadow into your terminal, your scrollback, and any session recording your team keeps.

Source order is not run order

Assembling flat-pack furniture, some steps genuinely have to come first. The door cannot go on before the frame exists. Other steps do not care: shelves before back panel, back panel before shelves, no difference to the finished thing. Puppet takes the same view of your manifest. It does not apply resources in the order you typed them merely because you typed them. It builds a graph out of the relationships you declared and walks that instead, which is also how it knows to skip everything downstream of a resource that failed rather than ploughing on and making a mess.

Four keywords declare a relationship. require means do that one before me. before means do me before that one. notify means before, plus send a refresh event if I changed anything. subscribe is notify read from the other end. The chaining arrows say the same thing between resource references and read nicely in a row: Package['openssh-server'] -> File['/etc/ssh/sshd_config.d/10-hardening.conf'] ~> Service['ssh']. The squiggly arrow is the one carrying the refresh.

Puppet also wires up some edges for you without being asked, a behaviour called autorequire. A file autorequires its parent directory when that directory is managed in the same catalog. An exec autorequires the user it runs as and the file it runs. A cron entry autorequires its user. Both ends have to be in the catalog for any of that to fire, and there is no autorequire between a package and a config file that package happens to own. That edge is yours to write, every single time.

terminal
$ puppet config print ordering
output
manifest

Ask for one setting and you get the bare value back. Since Puppet 4 the default has been manifest, meaning source order acts as the tie-break between resources with no declared relationship. Leaning on that is how you end up with a configuration that works by luck. The tie-break moves the moment a class gets included from somewhere else, or a resource moves into a module, or two people merge on the same afternoon. So go hunting for the missing edges on purpose. Copy the manifest, delete the require line, and run it on a throwaway virtual machine with random ordering.

terminal
$ sudo puppet apply --ordering=random /root/demo/ssh-nodeps.pp
output
Notice: Compiled catalog for test01.acme.internal in environment production in 0.34 seconds
Error: /Stage[main]/Main/File[/etc/ssh/sshd_config.d/10-hardening.conf]/ensure: change from 'absent' to 'file' failed: Could not set 'file' on ensure: No such file or directory @ rb_sysopen - /etc/ssh/sshd_config.d/10-hardening.conf20260721-4412-1b8k3wq
Notice: /Stage[main]/Main/Package[openssh-server]/ensure: created
Notice: Applied catalog in 11.42 seconds

The file lost the coin toss and tried to write into a directory the openssh-server package had not created yet. That odd temporary filename in the error is Puppet writing to a scratch file next to the target and renaming it into place, which is how it avoids leaving half a config file behind if something dies mid-write. Running one test node with --ordering=random in CI (continuous integration, the automated build that runs on every change) turns a latent bug like this into a red build today instead of a 3am surprise the week you refactor everything into modules. Declare the relationship, then leave ordering alone in production. Get the edges wrong in the other direction, so that A waits for B while B waits for A, and Puppet refuses the entire catalog with "Found 1 dependency cycle" and applies nothing at all. Pass --graph and it writes the graph out as a .dot file you can open in GraphViz and actually look at.

A dry run is an estimate, not a plan
--noop walks the catalog and changes nothing, which means every resource after the first one sees a machine that was never updated. A file whose parent directory would have been created by an earlier package still reports as a change that would happen. An exec guarded by onlyif is worse: the guard command really does run, against the old state, so a dry run can report the exact opposite of what a real run does. Read noop as a decent estimate of the first layer of changes, never as a promise about the whole run. Related trap: --show_diff is off by default, but puppet agent -t switches it on, and it prints the before and after contents of managed files into the log and into the report stored in PuppetDB. Set show_diff => false on any file resource carrying a credential, or wrap its content in Sensitive() so Puppet redacts the value.

What the run tells you

terminal
# same three resources, now living in a profile class on the server
$ sudo puppet agent -t
output
Info: Using environment 'production'
Info: Retrieving pluginfacts
Info: Retrieving plugin
Info: Retrieving locales
Info: Loading facts
Info: Caching catalog for web01.acme.internal
Info: Applying configuration version '1784626872'
Notice: /Stage[main]/Profile::Base::Ssh/File[/etc/ssh/sshd_config.d/10-hardening.conf]/content:
--- /etc/ssh/sshd_config.d/10-hardening.conf 2026-07-21 02:41:07.118453201 +0000
+++ /tmp/puppet-file20260721-3311-9d2x1a 2026-07-21 09:41:12.884120336 +0000
@@ -1,2 +1,2 @@
-PermitRootLogin yes
+PermitRootLogin no
PasswordAuthentication no
Notice: /Stage[main]/Profile::Base::Ssh/File[/etc/ssh/sshd_config.d/10-hardening.conf]/content: content changed '{md5}8db69b397be318c24fe25e3e34a73654' to '{md5}5a0a9b4347f8a6a919280336fbd7ebc0'
Notice: /Stage[main]/Profile::Base::Ssh/Service[ssh]: Triggered 'refresh' from 1 event
Notice: Applied catalog in 6.73 seconds
terminal
$ echo $?
output
2

Somebody had turned root login back on. The next run caught it, printed the difference line by line, rewrote the file, restarted sshd and told you all four things. The class path changed too, from Main to Profile::Base::Ssh, because on a real server that code lives inside a profile class instead of sitting loose at top scope. That is the roles and profiles pattern, the current way to structure Puppet code, and it gets its own lesson.

-t is short for --test, which flips on a bundle of options at once: --onetime, --verbose, --ignorecache, --no-daemonize, --no-usecacheonfailure, --detailed-exitcodes, --no-splay and --show_diff. The one to memorise is --detailed-exitcodes. Zero means the run was clean. Two means changes were applied, four means something failed, six means both, and one means the run never got off the ground at all, usually a catalog that would not compile. That single number turns a Puppet run into something your monitoring can read without parsing a line of log. On a host nobody has deployed to in a week, exit code 2 is your fleet telling you reality moved and Puppet moved it back. Alert on it.

terminal
$ facter os.family os.release.major networking.fqdn virtual
output
networking.fqdn => web01.acme.internal
os.family => Debian
os.release.major => 12
virtual => kvm

Those are the measurements the tailor cuts from, and manifests branch on them constantly. fqdn is the fully qualified domain name, the machine's full network name; virtual => kvm says this box is a guest on a Linux hypervisor rather than bare metal. There is a sharp edge in all of it. Facts come from the node, so the node can lie. A custom fact is Ruby code the node runs and reports. An external fact is a file dropped in /etc/puppetlabs/facter/facts.d, which anyone with root on that box can write. If a manifest reads a fact to decide "this is a production database host, give it the strict rules and the database password", then root on any node can claim to be one and be handed the password. Use ordinary facts to describe hardware and platform. For anything deciding privilege or secrets, use the trusted facts in $trusted, which the server takes from the node's signed certificate rather than from what the node says about itself. One version note while you are here: Facter 4, the version that ships with Puppet 8, dropped the old facter -p flag for loading the facts that modules supply. Use puppet facts show instead.

What Puppet will not do for you

Puppet enforces exactly what you modelled and not one thing more. An unmanaged file is invisible to it. A user account added by hand that no resource mentions will sit there for years while the agent reports no changes twice an hour, perfectly honestly, because everything it was told to check does match. A clean run means the model holds. It does not mean the machine is clean, and the gap between those two sentences is where compromises live.

You can ask for more. resources { 'user': purge => true } deletes every user on the box that the catalog does not declare. A file resource with recurse => true and purge => true does the same inside a directory, so declaring /etc/sudoers.d that way strips out any sudo rule you did not put there yourself. Both are sharp instruments. The sudoers one belongs in a hardened build. The user one has locked teams out of their own fleet in a single run, because the service accounts that packages install are real and rarely modelled. unless_system_user => true blunts it, though read the default before you lean on it: it spares root and anything with a UID (user ID number) of 500 or below, while modern distributions hand out system accounts all the way up to 999. Set the boundary yourself, purge one directory at a time, run it with --noop first, and never switch it on fleet-wide as a default.

The last gap is the agent itself. An attacker with root on a node can stop puppet.service, and the drift they introduce afterwards survives quietly, because a machine that stopped reporting produces no failed run for anyone to look at. Silence is not success. Query PuppetDB for nodes whose report_timestamp is older than a couple of run intervals, and treat that list as seriously as the failure list. Then prove the whole loop on one real host: break the hardening by hand, wait for the next run, and watch the file come back, the diff land in the report, and exit code 2 arrive in your monitoring. Until you have watched that happen end to end, you have written a document, not a control.

Quick check
01You add a file resource to a manifest, run puppet apply twice, and the second run prints only the "Compiled catalog" and "Applied catalog" lines. What does that tell you?
Incorrect — A failed compile prints an Error and never reaches the "Applied catalog" line at all.
Correct — that quiet second run is idempotence, and it is what makes running every 30 minutes safe.
Incorrect — Puppet re-inspects every resource on every run; it found nothing to change, it did not skip the check.
Incorrect — Noop is off unless you pass --noop or set noop = true in puppet.conf.
02A manifest declares a package, a config file that lives in a directory the package creates, and a service, with no require, before, notify or arrows anywhere. It has worked for a year. Which statement is accurate on Puppet 8?
Incorrect — A file autorequires its managed parent directory, not the package that happens to ship that directory.
Incorrect — Manifest order is only the tie-break between resources with no declared relationship, and it shifts as soon as code moves between classes and modules.
Incorrect — title-hash is a selectable ordering mode rather than the default since Puppet 4, and it deliberately scrambles order so that nobody can rely on it.
Correct — random ordering turns an undeclared dependency into a failure you can see today.
03A nightly puppet agent -t on web04 exits 2 and logs: File[/etc/ssh/sshd_config.d/10-hardening.conf]/content: content changed '{md5}8db6...' to '{md5}5a0a...'. Nobody deployed to web04 this week. What is the most useful reading?
Correct — the enforced state was overwritten locally and then reverted, which is exactly the event worth chasing.
Incorrect — With --detailed-exitcodes, 2 means changes were applied; 4 is the failure code and 1 means the run never started.
Incorrect — A non-idempotent resource would report a change every single night, and replace => false would stop Puppet enforcing the hardening at all.
Incorrect — A catalog change would show up across every node using that class, not as a one-host content revert.

Try this

Run sudo puppet config print --section agent environment runinterval splay splaylimit on a scratch host or disposable cluster and read the output against what this lesson described. Then change one input so it fails, and re-run: the error you get is the one you will meet in production.

Takeaway

The trap worth remembering here: puppet resource is not a read-only command. Check that on your own systems before you need to, because it is cheaper to find on a quiet afternoon than during an incident.

Related