Describe the machine, let Puppet make it so: resources, facts, dependencies, classes and modules, Hiera data, and a real server with agents, certificates and test environments, all run for real on a lab
Puppet: manifests, modules, Hiera and the agent/server model
puppet apply --noop site.pp
Puppet is a configuration management tool that works by DESCRIBING what a machine should look like, and then making it so, over and over. You do not write "install nginx"; you write "nginx is installed, this file has this content, this service is running", and Puppet works out what is missing. This page walks through the whole thing on a lab: the language, the resources you use every day, how changes ripple (a changed file restarts a service), classes and modules, where the data lives (Hiera), and then the real setup, a Puppet server with agents that ask it for their configuration. It also shows the places where Puppet quietly does something other than what you meant: a run that "succeeds" with exit code 0 after a failure, a service that works on the second run only, an unquoted file mode that stops everything.
Every output below ran for real, on 9 October 2026: Puppet 8.4.0, Facter 4.3.0, Puppet Server 8.4.0 (on Java 21), puppet-lint 4.2.4, Ruby 3.2.3, all from the Ubuntu 24.04 "universe" repository, on Docker containers. Containers are not servers: there is no systemd inside, so the service resource used the old init scripts (the Puppet code is the same, the provider under it is not). Other packagings and distributions may put their files elsewhere (the Ubuntu ones use /etc/puppet): ask Puppet with puppet config print instead of trusting a path from a web page, this one included. Where something was not run, this page says "not run in the lab".
The lab
Three containers on a private network, all built from one image (ubuntu:24.04 plus puppet-agent, puppet-lint, curl and sudo), started with docker run --init:
node1 a machine for puppet apply (no server needed)
puppet the Puppet Server (puppetserver, a Java program)
node2 an agent that talks to the server (hostname node2.lab)
The --init matters, and it cost me a wrong result. My first container had a plain sleep as process 1, and process 1 is responsible for reaping dead child processes. Without a reaper, a stopped nginx stays as a zombie ("[nginx] <defunct>"), the init script's stop then waits for it in vain, and Puppet's restart of the service failed:
Error: /Stage[main]/Main/Service[nginx]: Failed to call refresh: Could not stop Service[nginx]: Execution of '/etc/init.d/nginx stop' returned 1: * Stopping nginx nginx
...fail!
That is a property of my container, not of Puppet; with --init (Docker's option that adds a small init process) the same run works. It is here because the error looks like a Puppet bug and is not.
$ puppet --version; facter --version
8.4.0
4.3.0
Every apply runs as root on the machine it configures. A harmless line that appears at every run on this Ubuntu build, and that this page removes from the outputs to keep them readable:
/usr/lib/x86_64-linux-gnu/rubygems-integration/3.2.0/gems/ruby-augeas-0.5.0/lib/augeas.rb:48: warning: undefining the allocator of T_DATA class Augeas
The notice lines are printed by Puppet itself. With the colour switched off (--color=false) they are plain text.
How Puppet thinks
Three ideas carry everything:
resource one thing on the machine, with a type, a title and attributes: package { 'nginx': ensure => installed }. The types are the nouns: package, file, service, user, group, cron, exec and so on. "ensure" says whether it should exist.
catalog the complete list of resources, and the dependencies between them, that one machine should have. Puppet compiles your code into a catalog, then applies it.
fact something Puppet learns about the machine before it compiles: the operating system, the host name, the memory. Code can use facts to decide.
The cycle is always the same: gather facts, compile the catalog, compare each resource with the machine, change only what differs, report. Because only differences are changed, applying the same catalog twice is harmless. That property is called idempotence, and it is why you can run Puppet from a scheduler every half hour without fear.
There are two ways to run it:
puppet apply FILE compile and apply a manifest on the machine you are on. No server. Good for learning, testing and small setups
puppet agent ask a Puppet server for the catalog for THIS machine, then apply it. The server compiles; the agent only applies. This is how a fleet is run
Both are shown below; every other part (the language, classes, Hiera) is the same for both.
Facter: what the machine says about itself
$ facter os.name os.release.full os.family kernel
kernel => Linux
os.family => Debian
os.name => Ubuntu
os.release.full => 24.04
$ facter os
{
architecture => "amd64",
distro => {
codename => "noble",
description => "Ubuntu 24.04.5 LTS",
id => "Ubuntu",
release => {
full => "24.04",
major => "24.04"
}
},
family => "Debian",
hardware => "x86_64",
name => "Ubuntu",
release => {
full => "24.04",
major => "24.04"
},
selinux => {
enabled => false
}
}
A fact can be a single value or a whole structure (a hash), and you reach into it with brackets: $facts['os']['family']. Facts are read-only.
You can add your own facts without writing any Ruby: put a text file of name=value lines in the external facts directory. On this Ubuntu build it is /etc/facter/facts.d:
$ printf 'role=web\nowner=ops\n' > /etc/facter/facts.d/role.txt
$ facter role owner
owner => ops
role => web
$ puppet apply -e 'notice("this node is a ${facts["role"]}")'
Notice: Scope(Class[main]): this node is a web
That is the cheapest way to tell Puppet which job a machine has. (Ruby custom facts were not run in the lab.)
Asking the system: puppet resource
Puppet can show any resource as Puppet code, and that is the quickest way to learn an attribute's name and what values look like:
$ puppet resource package curl
package { 'curl':
ensure => '8.5.0-2ubuntu10.15',
mark => 'none',
provider => 'apt',
}
$ puppet resource user root
user { 'root':
ensure => 'present',
comment => 'root',
gid => 0,
home => '/root',
password => '*',
password_max_age => 99999,
password_min_age => 0,
password_warn_days => 7,
provider => 'useradd',
shell => '/bin/bash',
uid => 0,
}
$ puppet resource service cron
service { 'cron':
ensure => 'stopped',
enable => 'false',
provider => 'debian',
}
The provider line is the part that does the work for this system: apt for packages, useradd for users, and "debian" for services (the init scripts in this container; other providers such as systemd exist, and were not run in the lab). The same Puppet code works on other systems because the provider is chosen for you.
puppet describe --list shows the resource types (puppet describe TYPE documents one; I ran only the list).
puppet resource can also CHANGE things (see "Drift" below), which makes it a handy emergency tool and a way to test a resource before you write it down.
Your first manifest
A manifest is a file of Puppet code, ending in .pp. This one installs nginx, removes its default site, adds one small site, and makes sure the service runs:
$ cat web.pp
# A tiny web server: package, config, content, service
package { 'nginx':
ensure => installed,
}
file { '/etc/nginx/sites-enabled/default':
ensure => absent,
require => Package['nginx'],
notify => Service['nginx'],
}
file { '/etc/nginx/conf.d/hello.conf':
ensure => file,
content => "server {\n listen 80;\n root /var/www/hello;\n}\n",
require => Package['nginx'],
notify => Service['nginx'],
}
file { '/var/www/hello':
ensure => directory,
require => Package['nginx'],
}
file { '/var/www/hello/index.html':
ensure => file,
content => "<h1>Hello from ${facts['networking']['hostname']}</h1>\n",
}
service { 'nginx':
ensure => running,
enable => true,
}
The shape is always type { 'title': attribute => value, }. A resource reference, with the type capitalized, points at another resource: Package['nginx']. The double-quoted strings can contain variables (${...}) and escapes (\n).
Preview, apply, apply again, and the exit codes
First check the syntax, then ask what would happen:
$ puppet parser validate web.pp && echo syntax-ok
syntax-ok
$ puppet apply --noop web.pp
Notice: Compiled catalog for node1. in environment production in 0.31 seconds
Notice: /Stage[main]/Main/Package[nginx]/ensure: current_value 'purged', should be 'present' (noop)
Notice: /Stage[main]/Main/File[/etc/nginx/conf.d/hello.conf]/ensure: current_value 'absent', should be 'file' (noop)
Notice: /Stage[main]/Main/File[/var/www/hello]/ensure: current_value 'absent', should be 'directory' (noop)
Notice: /Stage[main]/Main/File[/var/www/hello/index.html]/ensure: current_value 'absent', should be 'file' (noop)
Notice: /Stage[main]/Main/Service[nginx]/ensure: current_value 'stopped', should be 'running' (noop)
Notice: Class[Main]: Would have triggered 'refresh' from 5 events
Notice: Stage[main]: Would have triggered 'refresh' from 1 event
Notice: Applied catalog in 1.45 seconds
Each line reads: this resource, this attribute, its current value, what it should be. Then the real run:
$ puppet apply web.pp
Notice: Compiled catalog for node1. in environment production in 0.31 seconds
Notice: /Stage[main]/Main/Package[nginx]/ensure: created
Notice: /Stage[main]/Main/File[/etc/nginx/sites-enabled/default]/ensure: removed
Notice: /Stage[main]/Main/File[/etc/nginx/conf.d/hello.conf]/ensure: defined content as '{sha256}f2dbcbca56da77abcfa31e765f37382a9d2f872728da508bf178fca2e3b73305'
Notice: /Stage[main]/Main/File[/var/www/hello]/ensure: created
Notice: /Stage[main]/Main/File[/var/www/hello/index.html]/ensure: defined content as '{sha256}dc7e5ffdec42ec7ca1f7cb763c0a984b9801b7f62780a44d94a4f233bd4f6c84'
Notice: /Stage[main]/Main/Service[nginx]/ensure: ensure changed 'stopped' to 'running'
Notice: Applied catalog in 7.02 seconds
And again, now with the flag that makes the exit code meaningful:
$ puppet apply --detailed-exitcodes web.pp
Notice: Compiled catalog for node1. in environment production in 0.32 seconds
Notice: Applied catalog in 1.42 seconds
$ echo $?
0
$ curl -s localhost/
<h1>Hello from node1</h1>
Nothing printed except the two summary lines: no resource needed a change. That is the test of a good manifest: the second run is silent.
The exit codes are the part to learn by heart, because they decide what a scheduler or a CI job sees:
without --detailed-exitcodes 0 when the catalog was applied, even if a RESOURCE FAILED. Only compile errors give 1
with --detailed-exitcodes 0 = nothing changed; 2 = something changed; 4 = something failed; 6 = both changed and failed
The first line deserves proof. Each of these manifests has a failing resource (a file in a directory that does not exist, a package that does not exist, an exec that returns 1). Plain puppet apply:
$ puppet apply noparent.pp
Error: /Stage[main]/Main/File[/opt/app/conf/a.conf]/ensure: change from 'absent' to 'file' failed: Could not set 'file' on ensure: No such file or directory - A directory component in /opt/app/conf/a.conf20261009-823-6kwcns.lock does not exist or is a dangling symbolic link
Notice: Applied catalog in 0.01 seconds
$ echo $?
0
and with the flag, for the three failing manifests (noparent, failexec, nopkg):
exit=4
exit=4
exit=4
"Applied catalog" and exit 0, after an error. Use --detailed-exitcodes in every script. A run with a failed restart and a changed file gave exit 6 in the lab.
One more surprise: --noop with --detailed-exitcodes returned 0 in my runs even though the preview listed pending changes (both on puppet apply and later on puppet agent). So you cannot test "would anything change?" with the exit code of a --noop run; read the output.
Drift: someone changes the machine by hand
Puppet repairs what it manages. Edit the config, stop the service, and run again:
$ sed -i 's/listen 80;/listen 8080;/' /etc/nginx/conf.d/hello.conf; service nginx stop
$ puppet apply --detailed-exitcodes web.pp
Notice: /Stage[main]/Main/File[/etc/nginx/conf.d/hello.conf]/content: content changed '{sha256}3e9e11e7...' to '{sha256}f2dbcbca...'
Notice: /Stage[main]/Main/Service[nginx]/ensure: ensure changed 'stopped' to 'running'
$ echo $?
2
$ grep listen /etc/nginx/conf.d/hello.conf
listen 80;
Both repaired, exit code 2. To see WHAT changed in a file, add --show_diff:
$ echo "<h1>Edited by hand</h1>" > /var/www/hello/index.html
$ puppet apply --show_diff web.pp
Notice: /Stage[main]/Main/File[/var/www/hello/index.html]/content:
--- /var/www/hello/index.html 2026-10-09 13:00:07.624752567 +0000
+++ /tmp/puppet-file20261009-445-oa33dq 2026-10-09 13:00:11.604788245 +0000
@@ -1 +1 @@
-<h1>Edited by hand</h1>
+<h1>Hello from node1</h1>
Notice: /Stage[main]/Main/File[/var/www/hello/index.html]/content: content changed '{sha256}9ecb01ad...' to '{sha256}dc7e5ffd...'
Now change the config while the service is RUNNING, and the service gets restarted by the change (that is the notify in the manifest):
$ sed -i 's/listen 80;/listen 9090;/' /etc/nginx/conf.d/hello.conf
$ puppet apply --detailed-exitcodes web.pp
Notice: /Stage[main]/Main/File[/etc/nginx/conf.d/hello.conf]/content: content changed '{sha256}4906fce0...' to '{sha256}f2dbcbca...'
Notice: /Stage[main]/Main/Service[nginx]: Triggered 'refresh' from 1 event
exit=2
"Triggered 'refresh'" is a service being reloaded or restarted because a resource it listens to changed.
puppet resource changes a resource right now, outside any manifest, and the next Puppet run puts it back:
$ puppet resource service nginx ensure=stopped
Notice: /Service[nginx]/ensure: ensure changed 'running' to 'stopped'
$ curl -s -o /dev/null -w "%{http_code}\n" localhost/
000
$ puppet apply --detailed-exitcodes web.pp
Notice: /Stage[main]/Main/Service[nginx]/ensure: ensure changed 'stopped' to 'running'
exit=2
Order and dependencies: require, notify, subscribe
Puppet applies the resources in the order they are WRITTEN in the manifest, unless a relationship says otherwise. A test with three notify resources:
$ puppet apply ord2.pp (first, second, third, written in that order; the Notify[...] lines are left out)
Notice: one
Notice: two
Notice: three
Written order is a good default and a dangerous one: it is easy to write something in an order that happens to work. Here the service is written BEFORE the package, with no dependency, on a machine without nginx:
$ puppet apply ord.pp
Error: Could not find init script for 'nginx'
Error: /Stage[main]/Main/Service[cron-demo]/ensure: change from 'stopped' to 'running' failed: Could not find init script for 'nginx'
Notice: /Stage[main]/Main/Package[nginx]/ensure: created
Notice: Applied catalog in 7.27 seconds
$ puppet apply --detailed-exitcodes ord.pp
Notice: /Stage[main]/Main/Service[cron-demo]/ensure: ensure changed 'stopped' to 'running'
exit=2
The first run fails (the service came first and the package did not exist yet), exit code 0; the package was installed anyway; the SECOND run succeeds. This is the classic "it works the second time" problem: it passes in a lab where you run twice and fails on a freshly installed machine that runs once. Write the dependency down, always.
The relationship words:
require => X this resource needs X first
before => X this resource must come before X
notify => X like before, and when this resource CHANGES, refresh X (restart a service, run a refreshonly exec)
subscribe => X like require, and refresh me when X changes
and the arrow form, which reads in order: Package['nginx'] -> File['/etc/nginx/conf.d/a.conf'] ~> Service['nginx'] (-> is order, ~> is order plus refresh).
Two things that make this friendlier than it sounds. A file automatically requires the file resource of its parent directory, if you declare one ("autorequire"). This worked with the children written first:
$ cat autoreq.pp
file { '/tmp/ar/sub/file.txt': ensure => file, content => 'hi' }
file { '/tmp/ar/sub': ensure => directory }
file { '/tmp/ar': ensure => directory }
$ puppet apply autoreq.pp
Notice: /Stage[main]/Main/File[/tmp/ar]/ensure: created
Notice: /Stage[main]/Main/File[/tmp/ar/sub]/ensure: created
Notice: /Stage[main]/Main/File[/tmp/ar/sub/file.txt]/ensure: defined content as '{sha256}8f434346...'
(Without any directory resource, there is nothing to depend on and the file fails: see "Error messages".) And when a service is started in the same run that also notified it, the pending refresh is cancelled, because starting it already applied the new configuration. The agent run later on this page shows it:
Notice: /Stage[main]/Webserver/Service[nginx]/ensure: ensure changed 'stopped' to 'running'
Info: /Stage[main]/Webserver/Service[nginx]: Unscheduling refresh on Service[nginx]
A cycle (A requires B, B requires A) is refused before anything is applied:
$ puppet apply cycle.pp
Error: Found 1 dependency cycle:
(File[/tmp/c1] => File[/tmp/c2] => File[/tmp/c1])\nTry the '--graph' option and opening the resulting '.dot' file in OmniGraffle or GraphViz
Error: Failed to apply catalog: One or more resource dependency cycles detected in graph
(That "\n" is literally in the message.)
The language in ten minutes
$ cat lang.pp
$os_family = $facts['os']['family']
$pkg = $os_family ? {
'Debian' => 'apache2',
'RedHat' => 'httpd',
default => 'unknown',
}
notice("web package for ${os_family}: ${pkg}")
case $facts['os']['name'] {
'Ubuntu', 'Debian': { notice('apt world') }
'Rocky': { notice('dnf world') }
default: { notice('who knows') }
}
if $facts['processors']['count'] > 4 and $facts['os']['hardware'] == 'x86_64' {
notice('a big x86 machine')
} else {
notice('a small or other machine')
}
$names = ['alpha', 'beta', 'gamma']
$names.each |Integer $i, String $n| { notice("${i}: ${n}") }
notice($names.map |$n| { $n.upcase })
notice($names.filter |$n| { $n =~ /a$/ })
notice(join($names, '-'))
$h = { 'x' => 1, 'y' => 2 }
notice($h['y'])
notice("math: ${1 + 2 * 3}, ${10 / 4}, ${10 % 4}")
notice(type(5))
notice(type('five'))
notice(type($names))
$x = 1
$x = 2
$ puppet apply lang.pp
Notice: Scope(Class[main]): web package for Debian: apache2
Notice: Scope(Class[main]): apt world
Notice: Scope(Class[main]): a big x86 machine
Notice: Scope(Class[main]): 0: alpha
Notice: Scope(Class[main]): 1: beta
Notice: Scope(Class[main]): 2: gamma
Notice: Scope(Class[main]): [ALPHA, BETA, GAMMA]
Notice: Scope(Class[main]): [alpha, beta, gamma]
Notice: Scope(Class[main]): alpha-beta-gamma
Notice: Scope(Class[main]): 2
Notice: Scope(Class[main]): math: 7, 2, 2
Notice: Scope(Class[main]): Integer[5, 5]
Notice: Scope(Class[main]): String
Notice: Scope(Class[main]): Tuple[String, String, String]
Error: Evaluation Error: Cannot reassign variable '$x' (file: /root/lang.pp, line: 33, column: 4) on node node1.
What it shows, in order:
A selector (value ? { ... }) picks a value; case and if choose what to do. The names are compared with == and =~ (regular expression).
Loops are functions with a block: each, map, filter. The block takes parameters between pipes.
10 / 4 is 2: integer division. Types are real, and checked (Integer, String, Hash[String, Hash]...).
Variables cannot be changed once set: the last two lines give "Cannot reassign variable". It is an error at compile time, which is the point: no resource is applied when the code is wrong.
notice() prints while the code is being compiled, notify { 'x': } is a RESOURCE that prints when it is applied (the "No role defined" example in the server section). Use notice() for debugging.
Use puppet apply -e 'CODE' for quick tests like these.
Classes, parameters, defined types and modules
A class is a named bundle of resources that you declare once per machine. A defined type is a bundle you can declare many times, each with its own title (one nginx site, one user account). A module is a directory with a fixed layout that holds classes, templates and files, and Puppet finds things in it by name. The module in this lab:
$ find modules -type f | sort
modules/webserver/files/robots.txt
modules/webserver/manifests/init.pp
modules/webserver/manifests/site.pp
modules/webserver/templates/index.html.erb
modules/webserver/templates/site.conf.epp
manifests/init.pp must define the class with the module's own name (webserver); manifests/site.pp must define webserver::site. That naming is how include webserver and webserver::site { 'shop': } are found (the autoloader). A class Puppet cannot find gives "Could not find class" (shown below for a class that does not exist at all; I did not test a misplaced file).
$ cat modules/webserver/manifests/init.pp
# @summary Installs nginx and one site per entry of $sites
class webserver (
Integer[1, 65535] $port = 80,
String $package = 'nginx',
Hash[String, Hash] $sites = {},
) {
package { $package:
ensure => installed,
}
file { '/etc/nginx/sites-enabled/default':
ensure => absent,
require => Package[$package],
notify => Service['nginx'],
}
service { 'nginx':
ensure => running,
enable => true,
require => Package[$package],
}
file { '/var/www/robots.txt':
ensure => file,
source => 'puppet:///modules/webserver/robots.txt',
require => Package[$package],
}
$sites.each |String $site, Hash $conf| {
webserver::site { $site:
message => $conf['message'],
port => $port,
}
}
}
$ cat modules/webserver/manifests/site.pp
# @summary One virtual host: config, web root and page
define webserver::site (
String $message,
Integer[1, 65535] $port = 80,
) {
file { "/etc/nginx/conf.d/${title}.conf":
ensure => file,
content => epp('webserver/site.conf.epp', { 'site' => $title, 'port' => $port }),
notify => Service['nginx'],
}
file { "/var/www/${title}":
ensure => directory,
}
file { "/var/www/${title}/index.html":
ensure => file,
content => template('webserver/index.html.erb'),
}
}
The class takes parameters with TYPES. Puppet checks them before anything is applied:
$ puppet apply --modulepath=modules -e 'class { "webserver": port => "abc" }'
Error: Evaluation Error: Error while evaluating a Resource Statement, Class[Webserver]: parameter 'port' expects an Integer value, got String (line: 1, column: 1) on node node1.
$ puppet apply --modulepath=modules -e 'class { "webserver": port => 70000 }'
Error: Evaluation Error: Error while evaluating a Resource Statement, Class[Webserver]: parameter 'port' expects an Integer[1, 65535] value, got Integer[70000, 70000] (line: 1, column: 1) on node node1.
Two ways to declare a class, and mixing them for the same class is refused (include first, then the resource-like form, as below; I did not test the reverse order):
include webserver uses defaults and Hiera data
class { 'webserver': port => 8080 } "resource-like": sets parameters in code, and wins over Hiera (shown in the Hiera section)
$ puppet apply --modulepath=modules --hiera_config=hiera.yaml -e 'include webserver
class { "webserver": port => 8080 }'
Error: Evaluation Error: Error while evaluating a Resource Statement, Duplicate declaration: Class[Webserver] is already declared; cannot redeclare (line: 2) (line: 2, column: 1) on node node1.
The same applies to resources: two file resources with the same path are refused (see the errors section). The defined type shows its strength in the loop: each entry of $sites becomes a webserver::site, with its own title ($title), and each one brings three resources.
Templates and files
Two template languages, both used in this module so that you can compare them:
EPP (Embedded Puppet) the modern one. Parameters are declared at the top and checked; the code in <% %> is Puppet
ERB (Embedded Ruby) the old one. Variables are those of the surrounding scope, written @name; the code is Ruby
$ cat modules/webserver/templates/site.conf.epp
<%- | String $site, Integer $port | -%>
# Managed by Puppet
server {
listen <%= $port %>;
server_name <%= $site %>.example.org;
root /var/www/<%= $site %>;
}
$ cat modules/webserver/templates/index.html.erb
<h1><%= @message %></h1>
<p>Site <%= @title %> on <%= @facts['networking']['hostname'] %>, <%= @facts['os']['name'] %> <%= @facts['os']['release']['major'] %>.</p>
You call them with epp('module/file.epp', { parameters }) and template('module/file.erb'). The result on the machine:
$ cat /etc/nginx/conf.d/shop.conf
# Managed by Puppet
server {
listen 80;
server_name shop.example.org;
root /var/www/shop;
}
$ for s in shop blog; do curl -s -H "Host: $s.example.org" localhost/; done
<h1>Welcome to the shop</h1>
<p>Site shop on node1, Ubuntu 24.04.</p>
<h1>Read the blog</h1>
<p>Site blog on node1, Ubuntu 24.04.</p>
A plain file is served from the module with source => 'puppet:///modules/webserver/robots.txt' (that is the file modules/webserver/files/robots.txt: the "files" directory is not in the path). It worked in puppet apply as well as from the server:
$ cat /var/www/robots.txt
User-agent: *
Disallow: /private/
puppet epp validate FILE checks a template's syntax (exit 0 for the one above). EPP declares its parameters, which is why it is the usual choice for new code; I did not compare how the two react to a mistake.
Hiera: data apart from code
Hiera keeps values out of the manifests, in YAML files, selected by a hierarchy: a list of file names with variables in them, searched from the top, and the first file that has the key wins. The configuration of this lab:
$ cat hiera.yaml
---
version: 5
defaults:
datadir: data
data_hash: yaml_data
hierarchy:
- name: "Per node"
path: "nodes/%{trusted.certname}.yaml"
- name: "Per OS family"
path: "os/%{facts.os.family}.yaml"
- name: "Everything else"
path: "common.yaml"
$ cat data/common.yaml
---
webserver::port: 80
webserver::sites:
shop:
message: Welcome to the shop
blog:
message: Read the blog
$ cat data/os/Debian.yaml
---
webserver::package: nginx
The magic: a class parameter is looked up automatically, under the key class_name::parameter_name. The class webserver has a parameter port, so webserver::port in a data file fills it, and no code mentions Hiera at all. The values shown above were produced by:
$ puppet lookup webserver::port --hiera_config=hiera.yaml
--- 80
$ puppet lookup webserver::sites --hiera_config=hiera.yaml
---
shop:
message: Welcome to the shop
blog:
message: Read the blog
and --explain shows the search, file by file:
$ puppet lookup webserver::port --hiera_config=hiera.yaml --explain
(trimmed to the second half)
Searching for "webserver::port"
Global Data Provider (hiera configuration version 5)
Using configuration "/root/proj/hiera.yaml"
Hierarchy entry "Per node"
Path "/root/proj/data/nodes/node1..yaml"
Original path: "nodes/%{trusted.certname}.yaml"
Path not found
Hierarchy entry "Per OS family"
Path "/root/proj/data/os/Debian.yaml"
Original path: "os/%{facts.os.family}.yaml"
No such key: "webserver::port"
Hierarchy entry "Everything else"
Path "/root/proj/data/common.yaml"
Original path: "common.yaml"
Found key: "webserver::port" value: 80
Look at the path of the first entry: node1..yaml, with TWO dots. This is a trap I walked into. The machine's name is "node1" with no domain, and Puppet builds its fully qualified name by adding a dot, so the certificate name (trusted.certname) is "node1." and the file would have to be called node1..yaml. In the lab:
$ puppet apply -e 'notice($trusted["certname"]); notice($facts["networking"]["fqdn"])'
Notice: Scope(Class[main]): node1.
Notice: Scope(Class[main]): node1..
A machine with a proper domain name (node2.lab, later on this page) has none of this. Give hosts real fully qualified names, or set the name explicitly with --certname. With a certname of node1.lab and a file data/nodes/node1.lab.yaml containing one line (webserver::port: 8081):
$ puppet lookup webserver::port --hiera_config=hiera.yaml --certname node1.lab
--- 8081
$ puppet apply --hiera_config=hiera.yaml --modulepath=modules --detailed-exitcodes --certname node1.lab site.pp
Notice: /Stage[main]/Webserver/Webserver::Site[shop]/File[/etc/nginx/conf.d/shop.conf]/content: content changed '{sha256}cff2b512...' to '{sha256}d74944a7...'
Notice: /Stage[main]/Webserver/Webserver::Site[blog]/File[/etc/nginx/conf.d/blog.conf]/content: content changed '{sha256}dfb2d730...' to '{sha256}29497d7b...'
Notice: /Stage[main]/Webserver/Service[nginx]: Triggered 'refresh' from 2 events
exit=2
$ curl -s -o /dev/null -w "8081: %{http_code}\n" -H "Host: shop.example.org" localhost:8081/
8081: 200
One value in one small file, per node, and Puppet works out the rest: both site files change, the service is refreshed once.
And which wins, Hiera or code? The resource-like declaration (class { 'webserver': port => 8082 }) wins over Hiera; I applied it and then the normal site.pp, and the first put port 8082 into the files and the second put 80 back. Not run in the lab: encrypted values (hiera-eyaml), lookup_options and merge strategies for hashes and arrays beyond the default, module-level and environment-level Hiera layers.
And: puppet lookup --node NAME needs the facts of that node to be stored, which a local setup does not have:
$ puppet lookup webserver::port --hiera_config=hiera.yaml --node node1
Error: Could not run: No facts available for target node: node1
Look up the local machine instead (as above), or give --certname.
exec: the escape hatch, and idempotence
When no resource type does what you need, exec runs a command. It is the one resource that is NOT idempotent by itself: with no guard it runs on every Puppet run. The guards are creates, unless, onlyif and refreshonly:
$ cat exec.pp
file { '/tmp/watched':
ensure => file,
content => "v1\n",
}
exec { 'always-changes':
command => '/bin/echo hi',
}
exec { 'once':
command => '/bin/touch /tmp/once',
creates => '/tmp/once',
}
exec { 'guarded':
command => 'echo made > /tmp/guarded',
provider => shell,
unless => 'test -f /tmp/guarded',
}
exec { 'react':
command => '/bin/echo reacted >> /tmp/react.log',
refreshonly => true,
subscribe => File['/tmp/watched'],
}
$ puppet apply --detailed-exitcodes exec.pp (first run)
Notice: /Stage[main]/Main/File[/tmp/watched]/ensure: defined content as '{sha256}2d27fbdf...'
Notice: /Stage[main]/Main/Exec[always-changes]/returns: executed successfully
Notice: /Stage[main]/Main/Exec[once]/returns: executed successfully
Notice: /Stage[main]/Main/Exec[guarded]/returns: executed successfully
Notice: /Stage[main]/Main/Exec[react]: Triggered 'refresh' from 1 event
exit=2
$ puppet apply --detailed-exitcodes exec.pp (second run)
Notice: /Stage[main]/Main/Exec[always-changes]/returns: executed successfully
exit=2
$ sed -i 's/v1/v2/' exec.pp ; puppet apply --detailed-exitcodes exec.pp (the file content changes)
Notice: /Stage[main]/Main/File[/tmp/watched]/content: content changed '{sha256}2d27fbdf...' to '{sha256}81db67b6...'
Notice: /Stage[main]/Main/Exec[always-changes]/returns: executed successfully
Notice: /Stage[main]/Main/Exec[react]: Triggered 'refresh' from 1 event
exit=2
$ cat /tmp/react.log
reacted
reacted
Read it as a table: the unguarded exec ("always-changes") runs every time and makes every run report "changed", exit 2, so you can never tell a quiet run from a busy one. creates (run only if the file does not exist) and unless (run only if the test fails) make the exec run once. refreshonly runs it ONLY when something it subscribes to changed: twice in total in this test (the file was created, and then changed), which is what the log shows. The rule: a run that changes nothing must say so, so guard every exec.
The agent and the server
For more than a few machines, run a server. The Ubuntu package puppetserver is a Java program: it started in about 40 seconds in the lab and used about 1 GB of memory (I set the Java heap to 512 MB to 768 MB; the package default is 1 GB). Without systemd I started it by hand as the puppet user, with a shortened version of the ExecStart line of the package's own service unit; the packaged service itself was not run in the lab. The server listens on port 8140. Where things live on this build (ask puppet config print for yours):
$ puppet config print codedir environmentpath basemodulepath ssldir confdir
codedir = /etc/puppet/code
environmentpath = /etc/puppet/code/environments
basemodulepath = /etc/puppet/code/modules:/usr/share/puppet/modules
ssldir = /var/lib/puppet/ssl
confdir = /etc/puppet
The server's code is an environment called production: a directory with manifests, modules and its own data and hiera.yaml. I copied the lab's module and data into it, and wrote the manifest that says which machine gets what:
$ find /etc/puppet/code/environments/production -type f | sort
.../production/data/common.yaml
.../production/data/os/Debian.yaml
.../production/hiera.yaml
.../production/manifests/site.pp
.../production/modules/webserver/files/robots.txt
.../production/modules/webserver/manifests/init.pp
.../production/modules/webserver/manifests/site.pp
.../production/modules/webserver/templates/index.html.erb
.../production/modules/webserver/templates/site.conf.epp
$ cat manifests/site.pp
node 'node2.lab' {
include webserver
}
node default {
notify { "No role defined for ${trusted['certname']}": }
}
A node block matches the certificate name; node default catches everybody else. (In real life you will probably use a classifier or Hiera instead of node blocks; that was not run in the lab.)
Names matter. The server's own certificate was made for "puppet." (the same trailing dot as before: its host name had no domain), with the alternative names "puppet" and "puppet.":
$ puppetserver ca list --all
Signed Certificates:
puppet. (SHA256) 3C:1A:D5:... alt names: ["DNS:puppet", "DNS:puppet."] authorization extensions: [pp_cli_auth: true]
The agents must reach the server under a name that is in the certificate. In the lab the server container had a network alias "puppet", and the agent runs below say --server puppet.
The first contact of a new agent. It creates a key and a certificate request, sends it, and stops, because nobody has signed it:
$ puppet agent --test --server puppet (on node2)
Info: Creating a new RSA SSL key for node2.lab
Info: Creating a new SSL certificate request for node2.lab
Info: Certificate Request fingerprint (SHA256): E3:6A:F0:20:38:...
Info: Certificate for node2.lab has not been signed yet
Couldn't fetch certificate from CA server; you might still need to sign this agent's certificate (node2.lab).
Exiting now because the waitforcert setting is set to 0.
(exit 1) On the server, you compare the fingerprint with the one the agent printed, and sign:
$ puppetserver ca list
Requested Certificates:
node2.lab (SHA256) E3:6A:F0:20:38:...
$ puppetserver ca sign --certname node2.lab
Successfully signed certificate request for node2.lab
That signature is what lets a machine ask for a catalog, and it is what keeps strangers out. (Automatic signing, "autosign", was not run in the lab and is a security decision.) Now the agent run, which downloads the certificate, the plug-ins, and the catalog, and applies it:
$ puppet agent --test --server puppet --detailed-exitcodes (on node2, trimmed)
Info: Downloaded certificate for node2.lab from https://puppet:8140/puppet-ca/v1
Info: Using environment 'production'
Notice: Requesting catalog from puppet:8140 (172.21.0.3)
Notice: Catalog compiled by puppet.
Info: Applying configuration version '1791551197'
Notice: /Stage[main]/Webserver/Package[nginx]/ensure: created
Notice: /Stage[main]/Webserver/File[/etc/nginx/sites-enabled/default]/ensure: removed
Info: /Stage[main]/Webserver/File[/etc/nginx/sites-enabled/default]: Scheduling refresh of Service[nginx]
Notice: /Stage[main]/Webserver/File[/var/www/robots.txt]/ensure: defined content as '{sha256}efc4db54...'
Notice: /Stage[main]/Webserver/Webserver::Site[shop]/File[/etc/nginx/conf.d/shop.conf]/ensure: defined content as '{sha256}cff2b512...'
Info: /Stage[main]/Webserver/Webserver::Site[shop]/File[/etc/nginx/conf.d/shop.conf]: Scheduling refresh of Service[nginx]
...
Notice: /Stage[main]/Webserver/Service[nginx]/ensure: ensure changed 'stopped' to 'running'
Info: /Stage[main]/Webserver/Service[nginx]: Unscheduling refresh on Service[nginx]
...
Notice: Applied catalog in 8.74 seconds
exit=2
$ for s in shop blog; do curl -s -H "Host: $s.example.org" localhost/; done
<h1>Welcome to the shop</h1>
<p>Site shop on node2, Ubuntu 24.04.</p>
<h1>Read the blog</h1>
<p>Site blog on node2, Ubuntu 24.04.</p>
Run it again, and, as with puppet apply, it is quiet:
$ puppet agent --test --server puppet --detailed-exitcodes
Notice: Requesting catalog from puppet:8140 (172.21.0.3)
Notice: Catalog compiled by puppet.
Notice: Applied catalog in 1.54 seconds
exit=0
The agent in daemon mode (it asks again every runinterval, 1800 seconds, so 30 minutes, by default) was not run in the lab: I ran every agent run by hand with --test. What that setting says on the agent:
$ puppet config print runinterval server environment certname ssldir
certname = node2.lab
environment = production
runinterval = 1800
server = puppet
ssldir = /var/lib/puppet/ssl
(The order is the one Puppet printed.)
A node nobody wrote a role for gets the default node, which printed a notice:
$ puppet agent --test --server puppet --certname node1.lab (after signing node1.lab)
Notice: Catalog compiled by puppet.
Notice: No role defined for node1.lab
Notice: /Stage[main]/Main/Node[default]/Notify[No role defined for node1.lab]/message: defined 'message' as 'No role defined for node1.lab'
exit=2
Environments and safe testing
Change the data or the code on the server, and the agents get the change on their next run: that is the point and the danger. First look at what a change would do on one machine, with --noop, which also shows the file diffs:
$ printf -- '---\nwebserver::port: 8083\n' > data/nodes/node2.lab.yaml (on the server)
$ puppet agent --test --noop (on node2)
Notice: /Stage[main]/Webserver/Webserver::Site[shop]/File[/etc/nginx/conf.d/shop.conf]/content:
--- /etc/nginx/conf.d/shop.conf 2026-10-09 13:06:47.434845670 +0000
+++ /tmp/puppet-file20261009-746-wwiebf 2026-10-09 13:08:18.929654296 +0000
@@ -1,6 +1,6 @@
# Managed by Puppet
server {
- listen 80;
+ listen 8083;
server_name shop.example.org;
root /var/www/shop;
}
...
Notice: /Stage[main]/Webserver/Service[nginx]: Would have triggered 'refresh' from 2 events
Notice: Applied catalog in 1.55 seconds
exit=0
$ curl -s -o /dev/null -w "still on 80 after noop: %{http_code}\n" -H "Host: shop.example.org" localhost:80/
still on 80 after noop: 200
(exit 0 again, with changes pending.) Then the real run changed both site files, refreshed nginx once, and the site answered on 8083:
$ puppet agent --test
Notice: /Stage[main]/Webserver/Service[nginx]: Triggered 'refresh' from 2 events
exit=2
$ curl -s -o /dev/null -w "8083: %{http_code}\n" -H "Host: shop.example.org" localhost:8083/
8083: 200
An environment is a complete copy of the code and data under its own name. I copied production to development on the server and set the node's port to 9000 there. One machine can run another environment on demand, without anything else changing:
$ puppet agent --test --noop --environment development (on node2)
Info: Using environment 'development'
Notice: /Stage[main]/Webserver/Webserver::Site[shop]/File[/etc/nginx/conf.d/shop.conf]/content:
...
- listen 8083;
+ listen 9000;
Notice: /Stage[main]/Webserver/Service[nginx]: Would have triggered 'refresh' from 2 events
$ puppet agent --test (back to production: nothing to do, the noop changed nothing)
Notice: Applied catalog in 1.52 seconds
exit=0
That is the safe way to try a change: put it in a test environment, run one machine against it with --noop, read the diff, then do it for real, then merge into production. (A permanent environment per machine, via a node classifier or the agent's own configuration file, and a code deployment tool such as r10k, were not run in the lab.)
When the server cannot compile
I broke the class on the server (a reference to a variable that does not exist) and ran an agent:
$ puppet agent --test --server puppet --detailed-exitcodes (on node2)
Error: Could not retrieve catalog from remote server: Error 500 on SERVER: Server Error: Evaluation Error: Unknown variable: 'nosuch'. (file: /etc/puppet/code/environments/production/modules/webserver/manifests/init.pp, line: 7, column: 22) on node node2.lab
Warning: Not using cache on failed catalog
Error: Could not retrieve catalog; skipping run
exit=1
(The lines I left out are the Notice lines that stdout printed; they arrive in a different order than the errors on stderr.) The agent skipped the run and did NOT fall back to its cached catalog ("Not using cache on failed catalog"): the machine simply stays as it is. The error is also in the server's log, with the file and line:
$ grep ERROR /var/log/puppetserver/puppetserver.log | tail -2
2026-10-09T13:08:48.886Z ERROR [qtp190835435-62] [puppetserver] Puppet Evaluation Error: Unknown variable: 'nosuch'. (file: /etc/puppet/code/environments/production/modules/webserver/manifests/init.pp, line: 7, column: 22) on node node2.lab
2026-10-09T13:08:48.889Z ERROR [qtp190835435-62] [puppetserver] Puppet Server Error: Evaluation Error: Unknown variable: 'nosuch'. (file: ...
After fixing the file the next run was quiet again (exit 0).
Two certificate problems:
$ puppet agent --test --server plab-server (a name that is not in the certificate)
Error: Server hostname 'plab-server' did not match server certificate; expected one of puppet., DNS:puppet, DNS:puppet.
Error: Could not retrieve catalog from remote server: Server hostname 'plab-server' did not match server certificate; expected one of puppet., DNS:puppet, DNS:puppet.
Warning: Not using cache on failed catalog
Error: Could not retrieve catalog; skipping run
and removing a machine's certificate on the server, which is how you retire a node:
$ puppetserver ca clean --certname node1.lab
Certificate for node1.lab has been revoked
Cleaned files related to node1.lab
$ puppet agent --test --server puppet --certname node1.lab (the agent still has its old certificate)
Error: Could not retrieve catalog from remote server: Request to https://puppet:8140/puppet/v3/catalog/node1.lab?environment=production failed after 0.0 seconds: SSL_read: sslv3 alert certificate unknown
Wrapped exception:
SSL_read: sslv3 alert certificate unknown
"sslv3 alert certificate unknown" is the server saying it does not know this certificate any more. (Enrolling the same name again, which needs the agent's old SSL files removed, was not run in the lab.)
Lint and validate
Three tools check code without applying it: puppet parser validate (syntax), puppet epp validate (templates) and puppet-lint (style and common mistakes). On a manifest that is full of bad habits:
$ cat sloppy.pp
class sloppy {
file { "/tmp/x":
ensure => present,
mode => 644,
content => "hello",
}
package {'curl': ensure => latest }
$Bad_Name = 'x'
exec { 'run': command => '/bin/true' }
}
$ puppet-lint sloppy.pp
ERROR: sloppy not in autoload module layout on line 1 (check: autoloader_layout)
WARNING: class not documented on line 1 (check: documentation)
WARNING: mode should be represented as a 4 digit octal value or symbolic mode on line 4 (check: file_mode)
WARNING: unquoted file mode on line 4 (check: unquoted_file_mode)
WARNING: double quoted string containing no variables on line 2 (check: double_quoted_strings)
WARNING: double quoted string containing no variables on line 5 (check: double_quoted_strings)
WARNING: variable contains an uppercase letter on line 8 (check: variable_is_lowercase)
exit=1
puppet-lint --fix repaired four of the seven (the mode and the quotes), and left the other three for you:
FIXED: mode should be represented as a 4 digit octal value or symbolic mode on line 4 (check: file_mode)
FIXED: unquoted file mode on line 4 (check: unquoted_file_mode)
FIXED: double quoted string containing no variables on line 2 (check: double_quoted_strings)
FIXED: double quoted string containing no variables on line 5 (check: double_quoted_strings)
WARNING: variable contains an uppercase letter on line 8 (check: variable_is_lowercase)
(plus the layout error and the missing documentation). The module in this lab passed, with the line "puppet-lint modules/webserver/manifests" printing nothing and exit 0, and so did puppet parser validate on all three files.
The unquoted mode is more than style. In Puppet 8 it is an ERROR, and the error stops the WHOLE catalog, including the two correct resources next to it (none of the three files was created):
$ puppet apply -e 'file { "/tmp/m1": ensure => file, mode => 644 }
file { "/tmp/m2": ensure => file, mode => "0644" }
file { "/tmp/m3": ensure => file, mode => "u=rw,go=r" }'
Error: Parameter mode failed on File[/tmp/m1]: The file mode specification must be a string, not 'Integer' (line: 1)
With the two quoted forms on their own, both gave 644:
/tmp/m2 644 -rw-r--r--
/tmp/m3 644 -rw-r--r--
Always quote modes: "0644" or "u=rw,go=r". The linter would have told you.
Error messages
Each of these ran in the lab, from a small file.
Syntax error at end of input an unclosed brace or quote
Error: Could not parse for environment production: Syntax error at end of input (file: /root/err/syntax.pp) on node node1.
Duplicate declaration two resources of one type with one title (or a class declared twice)
Error: Evaluation Error: Error while evaluating a Resource Statement, Duplicate declaration: File[/tmp/dup] is already declared at (file: /root/err/dup.pp, line: 1); cannot redeclare (file: /root/err/dup.pp, line: 2)
Unknown variable a variable that is not defined: an error since Puppet 8, not an empty string
Error: Evaluation Error: Unknown variable: 'nosuch'. (file: /root/err/unkvar.pp, line: 1, column: 22) on node node1.
Unknown resource type a typo in the type name
Error: Evaluation Error: Error while evaluating a Resource Statement, Unknown resource type: 'packge' (file: /root/err/badtype.pp, line: 1, column: 1) on node node1.
no parameter named a typo in an attribute
Error: no parameter named 'enusre' (file: /root/err/badparam.pp, line: 1) on File[/tmp/bp] (file: /root/err/badparam.pp, line: 1) on node node1.
Could not find resource a require or notify that points at a resource that does not exist
Error: Could not find resource 'Package[nope]' in parameter 'require' (file: /root/err/badreq.pp, line: 1) on node node1.
Could not find class the class does not exist, or its file is in the wrong place (see the module layout)
Error: Evaluation Error: Error while evaluating a Function Call, Could not find class ::nosuch::class for node1. (file: /root/err/nocls.pp, line: 1, column: 1) on node node1.
Found 1 dependency cycle (shown above, in the order section)
Could not set 'file' on ensure: No such file or directory a file whose parent directory does not exist and is not managed (the exit code is 0 without --detailed-exitcodes, 4 with)
Execution of '/usr/bin/apt-get ...' returned 100 a package that apt cannot find (also exit 0 plain, 4 with the flag)
E: Unable to locate package no-such-package-xyz
returned 1 instead of one of [0] an exec that failed. The resources that depend on it are SKIPPED:
Error: '/bin/false' returned 1 instead of one of [0]
Notice: /Stage[main]/Main/File[/tmp/after-boom]: Dependency Exec[boom] has failures: true
Warning: /Stage[main]/Main/File[/tmp/after-boom]: Skipping because of failed dependencies
parameter 'port' expects an Integer value, got String a class parameter of the wrong type (shown above)
The file mode specification must be a string an unquoted mode (shown above)
Server hostname ... did not match server certificate the agent reaches the server by a name that is not in its certificate
Certificate for NAME has not been signed yet sign it on the server with puppetserver ca sign
Not using cache on failed catalog the server could not compile; the run is skipped
Cannot reassign variable a variable set twice; make a new name
What was not run in the lab
- Real machines, systemd (the services used init scripts), the agent running as a daemon or from cron, and the server as a packaged service
- Other operating systems: Red Hat family, SUSE, Windows, macOS. The code above has a RedHat branch for the web package name that was never executed
- Puppet's own packages and repositories (only the Ubuntu 24.04 build was used), Puppet Enterprise, and any version other than 8.4.0
- PuppetDB, exported resources, reports and dashboards, the node classifier, Bolt, r10k and other code deployment
- Automatic certificate signing, certificate re-enrolment after a clean, an intermediate CA
- Ruby custom facts, custom types and providers, virtual resources, resource collectors, stages, puppet strings, rspec-puppet and the Puppet Development Kit
- Hiera encrypted values, merge strategies and module-level data; Forge modules beyond installing one (puppetlabs-stdlib v10.1.0 downloaded and installed from forgeapi.puppet.com; none of its functions were used)
- Any large fleet: everything here was one server and two agents
- puppet apply with the real --graph output (the message mentions it; I did not open a graph)
Recipes
Check the syntax and the style before anything else:
puppet parser validate site.pp && puppet-lint manifests modules
Preview a change, with the file diffs:
puppet apply --noop --show_diff site.pp
Apply it, with an exit code a script can use (0 nothing, 2 changed, 4 failed, 6 both):
puppet apply --detailed-exitcodes site.pp
Run a manifest that uses your modules and data:
puppet apply --modulepath=modules --hiera_config=hiera.yaml site.pp
Quick test of one line of code:
puppet apply -e 'notice($facts["os"]["family"])'
See any resource as code, or change it now:
puppet resource package nginx puppet resource service nginx ensure=stopped
What does Hiera say, and where does it come from:
puppet lookup webserver::port --hiera_config=hiera.yaml --explain
Add your own fact without Ruby:
printf 'role=web\n' > /etc/facter/facts.d/role.txt (this path is for the Ubuntu build; check yours)
The agent, by hand, for one run:
puppet agent --test --server puppet --detailed-exitcodes
The same, previewing, against a test environment:
puppet agent --test --noop --environment development
On the server: list, sign and retire machines:
puppetserver ca list puppetserver ca sign --certname NAME puppetserver ca clean --certname NAME
Where is everything on THIS machine:
puppet config print codedir environmentpath ssldir confdir
Common problems
Symptom Usually
Exit code 0 and an Error line puppet apply without --detailed-exitcodes: resource failures do not change the exit code
Works on the second run only a missing dependency: the resource was written before what it needs. Add require/before, or put the resources in order
--noop says "would change", exit code 0 that is normal here: do not use the exit code of a noop run, read the output
The service did not restart after a change the file has no notify (or the service no subscribe). Refreshes only happen on a change, in the same run
Whole run fails on one resource's parameter parameter errors (an unquoted mode, a type mismatch) stop the catalog before anything is applied
"Unknown variable" a typo, or a variable that was never set (an error since Puppet 8, not an empty string)
Hiera file is never found for a node the certname is not what you think: print $trusted['certname'] (a host name without a domain gets a trailing dot)
Class parameter ignores the data file the key must be class::parameter, and the data must be in the hierarchy; check with puppet lookup --explain
"Duplicate declaration" on a class include and class { } for the same class: use only one form, and the resource-like one first
Every run reports changes an exec without creates/unless/onlyif/refreshonly, or a file with different content every time (a timestamp in a template)
Agent: "has not been signed yet" sign the request on the server; compare the fingerprints first
Agent: "did not match server certificate" use a server name that is in the certificate (puppetserver ca list --all shows them)
The agent applied nothing after a server error the server could not compile, and the cached catalog is not used. Read the server log
A stopped service stays "defunct" in a container no init process in the container: docker run --init
Tips
Preview with --noop --show_diff, apply with --detailed-exitcodes, and run it a second time: the second run must print nothing.
Write dependencies down. Written order works until the day it does not.
One class per job, in a module, with typed parameters and sensible defaults; put the values that differ per machine in Hiera.
Use EPP templates for new code, quote every string and every mode, and let puppet-lint nag you.
Guard every exec (creates, unless, onlyif, refreshonly), and use a real resource type whenever there is one.
Test on one machine first: a test environment and --noop on a single agent is cheap insurance.
Give machines real, fully qualified names before you build the certificate and Hiera layout on them.
Never run a script on a failed catalog: use the exit codes, and read the whole output of a failed run, because the first Error is often the cause and the rest are skipped dependents.
Keep the code in version control, and the Puppet server's code directory a copy of it, not the other way round.
Only manage machines you are authorized to manage: a Puppet server can change every machine that trusts it.