What Puppet is & the model
Declarative, agent/master, model-driven.
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.
$ sudo puppet config print --section agent environment runinterval splay splaylimit
environment = productionruninterval = 1800splay = falsesplaylimit = 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
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.
$ sudo puppet apply --noop /root/demo/ssh.pp
Notice: Compiled catalog for web01.acme.internal in environment production in 0.38 secondsNotice: /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 eventNotice: Class[Main]: Would have triggered 'refresh' from 1 eventNotice: Stage[main]: Would have triggered 'refresh' from 1 eventNotice: 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.
$ sudo puppet apply /root/demo/ssh.pp
Notice: Compiled catalog for web01.acme.internal in environment production in 0.36 secondsNotice: /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 eventNotice: Applied catalog in 1.21 seconds
# nothing touched on the box, nothing edited in the manifest$ sudo puppet apply /root/demo/ssh.pp
Notice: Compiled catalog for web01.acme.internal in environment production in 0.35 secondsNotice: 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.
$ puppet resource service ssh
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.
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.
$ puppet config print ordering
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.
$ sudo puppet apply --ordering=random /root/demo/ssh-nodeps.pp
Notice: Compiled catalog for test01.acme.internal in environment production in 0.34 secondsError: /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-1b8k3wqNotice: /Stage[main]/Main/Package[openssh-server]/ensure: createdNotice: 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.
What the run tells you
# same three resources, now living in a profile class on the server$ sudo puppet agent -t
Info: Using environment 'production'Info: Retrieving pluginfactsInfo: Retrieving pluginInfo: Retrieving localesInfo: Loading factsInfo: Caching catalog for web01.acme.internalInfo: 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 noPasswordAuthentication noNotice: /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 eventNotice: Applied catalog in 6.73 seconds
$ echo $?
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.
$ facter os.family os.release.major networking.fqdn virtual
networking.fqdn => web01.acme.internalos.family => Debianos.release.major => 12virtual => 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.
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.