CoursesPuppetRoles & profiles

Roles & profiles

The standard design pattern.

Advanced12 min · lesson 10 of 12

A car plant does not hand you a bin of three thousand parts and wish you luck. It sells you a trim level: the base model, the Sport, the Limited. Behind each trim sits a short, fixed list of subassemblies (the brake set, the infotainment stack, the seat frame), and behind those sit the individual parts. Roles and profiles is that arrangement applied to servers. Component modules from the Puppet Forge (the public library of shared Puppet code) are the parts. A profile is a subassembly: one slice of technology, wired up the way your team wants it. A role is the trim level: the exact list of subassemblies a given kind of machine gets. Every node is built to exactly one trim. That is the whole pattern.

The reason to bother is that two very different questions get asked about a server, usually by two different people. "What is this box?" is a business question, and the answer should read like a sentence: it is a payments API server. "Which encryption settings does its web server accept?" is a technical question with a fiddly answer that changes the next time somebody upgrades a module. Roles answer the first question. Profiles answer the second. Keep them in separate files and both answers stay short.

There is a security reason too, and people usually notice it late. A role file is four lines and takes ten seconds to review. The profiles it names are where privilege actually gets handed out: the sudo rules (who is allowed to run commands as root), the SSH (Secure Shell, the encrypted remote login protocol) policy, the packages, the cron entries (jobs the machine runs on a schedule, unattended). Someone with write access to your control repository (the Git repository that holds all your Puppet code) who wants root on every machine you own does not touch a role. They add three lines to the profile that every role includes, then wait for the next run. Knowing which files carry that weight is what turns branch protection and code owners into a real control instead of a checkbox.

Where the Two Layers Live

Puppet finds code on a module path, which behaves like the PATH your shell searches when you type a command: a list of directories, checked in order, first match wins. A control repository splits that list in two on purpose. The modules/ directory holds Forge code you did not write, listed in a Puppetfile and deployed by r10k or Code Manager (the tools that turn your Git branches into Puppet environments on the server). The site-modules/ directory holds code that only makes sense at your company, which in practice means exactly two modules: profile and role. Same mechanism, very different trust level. That is why they get separate directories and usually separate reviewers.

terminal
cd ~/control-repo
tree -L 3 --dirsfirst
output
.
├── data
│ ├── nodes
│ │ └── web01.example.com.yaml
│ ├── os
│ │ └── RedHat.yaml
│ └── common.yaml
├── manifests
│ └── site.pp
├── modules
├── site-modules
│ ├── profile
│ │ ├── manifests
│ │ ├── spec
│ │ └── metadata.json
│ └── role
│ ├── manifests
│ ├── spec
│ └── metadata.json
├── environment.conf
├── hiera.yaml
└── Puppetfile
12 directories, 9 files
environment.conf
modulepath = site-modules:modules:$basemodulepath
config_version = 'scripts/config_version.sh $environmentpath $environment'

site-modules comes first, so if a local module and a Forge module ever collide on a name, yours wins. $basemodulepath at the end covers the module directories Puppet ships with, outside any environment. Notice that modules/ is empty in Git: r10k fills it at deploy time from the Puppetfile, which is the whole reason the two directories are worth keeping apart. Both profile and role are ordinary modules, each with a manifests/ directory (manifests are the .pp files your Puppet code lives in) and a metadata.json. The Puppet Development Kit (PDK, Puppet's scaffolding and testing tool) will generate them: run pdk new module profile --skip-interview inside site-modules/, then pdk new class webserver inside the module to get manifests/webserver.pp. If you run OpenVox, the community fork that appeared after the Puppet 8 licence change, the commands and paths in this lesson are the same.

A Profile Is Your Opinion, Written Down

A profile is a recipe card. The flour and the eggs come from the shop, and the card tells you how much of each, in what order, in your kitchen. In Puppet terms: a profile takes one piece of technology, declares the component modules that do the real work, adds the local glue those modules cannot know about, and sets the order things happen in. Two habits keep profiles safe to recombine. First, every class comes in with include or contain, never with the resource-like class { 'name': } form. Declaring a class with include a second time does nothing at all, so any number of profiles can pull in the same base without arguing. Second, values arrive from Hiera (Puppet's data lookup system, a stack of data files searched from most specific to most general) through automatic parameter lookup: name a parameter $login_banner in class profile::base::hardening and Puppet goes looking for the Hiera key profile::base::hardening::login_banner by itself, with nobody passing anything by hand.

site-modules/profile/manifests/base.pp
# The floor. Every role gets this. No parameters, no exceptions.
class profile::base {
# contain, not include: so that an ordering arrow pointed at
# Class['profile::base'] actually covers what is inside it.
contain profile::base::hardening
contain profile::base::monitoring
}
site-modules/profile/manifests/base/hardening.pp
# Baseline hardening. Values come from Hiera, never hardcoded here.
class profile::base::hardening (
String[1] $login_banner = 'Authorized use only.',
) {
include ssh # saz/ssh - server_options live in Hiera
include sudo # saz/sudo
include chrony # puppet/chrony - wrong clocks make logs fiction
file { '/etc/issue.net':
ensure => file,
owner => 'root',
group => 'root',
mode => '0644',
content => "${login_banner}\n",
}
}
data/common.yaml
---
# The SSH policy for every node in this environment.
# Reviewed like code, because it is code.
ssh::server_options:
PermitRootLogin: 'no'
PasswordAuthentication: 'no'
X11Forwarding: 'no'
profile::base::hardening::login_banner: >-
Authorized use only. All activity on this system is monitored and recorded.
site-modules/profile/manifests/webserver.pp
class profile::webserver (
Stdlib::Port $listen_port = 8080,
Stdlib::Absolutepath $docroot = '/var/www/app',
) {
include profile::base
contain nginx # contain, not include: see below
nginx::resource::server { 'app':
listen_port => $listen_port,
www_root => $docroot,
}
# Ordering belongs to the profile, never to the role.
Class['profile::base'] -> Nginx::Resource::Server['app']
}

contain deserves its own paragraph, because the difference costs people whole afternoons. include nginx puts the nginx class into the catalog (the finished list of resources, meaning the files, packages, services and users, that Puppet builds for one machine on every run), but it does not put nginx inside profile::webserver. An arrow like Class['profile::webserver'] -> Class['profile::app_deploy'] then orders only the resources declared directly in the profile. The nginx package and service sit outside that fence and are free to land after the deploy has already run. Think of include as inviting someone to the party and contain as putting them in your car: only the second one means they arrive when you do. That is also why profile::base above uses contain for its two sub-profiles. Without it, the arrow at the bottom of profile::webserver would point at an empty fence and order nothing. Use include by default, and reach for contain the moment you write an ordering relationship that involves the class.

A Role Is One Sentence About the Machine

A role names a machine type and lists the profiles that make it. Nothing else lives there: no parameters, no resources, no conditionals, no Hiera lookups, no ordering arrows. The rule sounds fussy right up until the night you need to know what a broken host is supposed to be, and the answer is four lines of plain reading instead of an archaeology expedition.

site-modules/role/manifests/app_server.pp
# What is this machine? Answered in four lines.
class role::app_server {
include profile::base
include profile::webserver
include profile::app_deploy
}
# No parameters. No resources. No conditionals. No lookups. No arrows.
# If this file ever needs an 'if', the condition belongs in a profile.

When two machine types overlap but differ, resist the urge to give one node two roles. Write a third role that lists the union of the two. One node, one role, always, because classification then stays a single value you can query, graph and alert on. Two roles means there is no single answer to "what is this box", and the first thing that breaks is your ability to audit the fleet.

Where do the conditionals go, then? Into a profile, where you can unit test both branches. Or into Hiera, which already has a hierarchy built for "RedHat does this, Debian does that", keyed on facts (the machine details Puppet collects at the start of every run: operating system, hostname, addresses, hardware). A role with an if in it has quietly turned into a profile with a misleading name.

Three layers, and what each one is allowed to contain
Component modules (modules/)
nginx, ssh, sudo, chrony
Forge code, pinned in the Puppetfile
Generic and configurable
knows nothing about your company
Never edited in place
upgrade the version, do not patch the copy
Profiles (site-modules/profile/)
One technology, your way
include and contain only, never class { }
Data comes from Hiera
automatic parameter lookup fills the params
Owns all the ordering
contain, chaining arrows, require, notify
Where privilege actually lives
review these diffs hardest
Roles (site-modules/role/)
One machine type
include statements for profiles, nothing else
No data, no logic
no params, no resources, no if, no lookup
Exactly one per node
assigned from Hiera or a node classifier
Every arrow points down. A node names one role, a role names profiles, a profile names component modules. Nothing ever points back up: a component module must never know a profile exists.

One Role Per Node, Assigned From Data

Classification is the act of attaching one role to one node. You can write a node block per host in site.pp, and it works, and it ages badly. Store the role in Hiera instead, in a file named after the node's certificate name (the unique identity Puppet issues each machine when it first checks in), and look it up once. Classification becomes data you can diff, query and back up, rather than code somebody has to read.

manifests/site.pp
node default {
# One role per node, resolved from data. No per-host node blocks.
$role = lookup('role', String[1])
include "role::${role}"
}
data/nodes/web01.example.com.yaml
---
role: 'app_server'
profile::webserver::listen_port: 8080

lookup('role', String[1]) is typed and has no default, so a node with no role in data fails to compile. That is the behaviour you want. A node that silently receives an empty catalog is a node with no hardening, no monitoring and no audit rules, and it will sit there looking perfectly healthy on every dashboard you own. A loud compile failure gets fixed the same morning. The machine does not lose the policy it already has, either: on a normal scheduled run the agent falls back to its last cached catalog, so it keeps enforcing yesterday's state, stops receiving new state, and reports the failure. That fallback is the usecacheonfailure setting, which is on by default, and puppet agent --test deliberately turns it off so that a human running a test sees the error rather than a stale success. If you run Puppet Enterprise or an external node classifier (an ENC, a script your server calls to ask what a given node should be), it hands out the same single role and nothing else in this lesson changes.

Before you push, check that the data resolves the way you believe it does. puppet lookup runs the same hierarchy the compiler will run, for whichever node you name.

terminal
# What role will web01 actually get?
puppet lookup role --node web01.example.com --environment production
# Where did that answer come from, and what got checked first?
puppet lookup role --node web01.example.com --environment production --explain
output
--- app_server
Searching for "role"
Global Data Provider (hiera configuration version 5)
Using configuration "/etc/puppetlabs/puppet/hiera.yaml"
Hierarchy entry "Common defaults"
Path "/etc/puppetlabs/puppet/data/common.yaml"
Original path: "common.yaml"
Path not found
Environment Data Provider (hiera configuration version 5)
Using configuration "/etc/puppetlabs/code/environments/production/hiera.yaml"
Hierarchy entry "Per-node data"
Path "/etc/puppetlabs/code/environments/production/data/nodes/web01.example.com.yaml"
Original path: "nodes/%{trusted.certname}.yaml"
Found key: "role" value: "app_server"

Reach for --explain whenever a node gets the wrong role, or a parameter you cannot account for. It prints every file it opened, in priority order, and marks the one that answered. (Real output starts with a short lookup_options search above what you see here; ignore that section unless you are chasing merge behaviour.) Interpolations like %{facts.os.family} need the node's facts before they can resolve, so on the Puppet server --node pulls the facts the server already has on file. Anywhere else, hand them over with --facts facts.json. If the value you are chasing depends on variables set during compilation, add --compile.

Prove It Before the Fleet Sees It

Two checks catch nearly everything this pattern can get wrong, and neither needs a production machine. The first is a dry run. --noop (no operation) compiles the real catalog against the real classification and reports every change it would make, without making a single one.

terminal
sudo puppet agent -t --noop
output
Info: Using configured environment 'production'
Info: Retrieving pluginfacts
Info: Retrieving plugin
Info: Retrieving locales
Info: Loading facts
Info: Caching catalog for web01.example.com
Info: Applying configuration version '1753101122'
Notice: /Stage[main]/Profile::Base::Hardening/File[/etc/issue.net]/ensure: current_value 'absent', should be 'file' (noop)
Notice: Class[Profile::Base::Hardening]: Would have triggered 'refresh' from 1 event
Notice: /Stage[main]/Nginx::Package::Redhat/Package[nginx]/ensure: current_value 'absent', should be 'present' (noop)
Notice: Class[Nginx::Package::Redhat]: Would have triggered 'refresh' from 1 event
Notice: /Stage[main]/Nginx::Service/Service[nginx]/ensure: current_value 'stopped', should be 'running' (noop)
Notice: Class[Nginx::Service]: Would have triggered 'refresh' from 1 event
Notice: Stage[main]: Would have triggered 'refresh' from 3 events
Notice: Applied catalog in 4.02 seconds

Read that as a list of promises. Every line starts with the class that owns the change, which is the fastest way to spot a profile landing where it has no business being. Read what is missing, too. If Profile::Base::Hardening stops appearing on a node that had it last week, your classification changed and nobody told you. On a scratch machine that already has the code deployed you can skip the server entirely: puppet apply --noop --environment production -e 'include role::app_server'.

The second check runs in CI (continuous integration, the automated checks that fire on every pull request) and covers every role at once. rspec-puppet compiles each role's catalog in memory against a set of pretend facts, so no machine is touched, and PDK runs it for you.

site-modules/role/spec/classes/app_server_spec.rb
require 'spec_helper'
describe 'role::app_server' do
on_supported_os.each do |os, os_facts|
context "on #{os}" do
let(:facts) { os_facts }
# The single highest-value test in a Puppet codebase.
it { is_expected.to compile.with_all_deps }
it { is_expected.to contain_class('profile::base::hardening') }
it { is_expected.to contain_file('/etc/issue.net').with_mode('0644') }
end
end
end
terminal
cd site-modules/role
pdk test unit --tests=spec/classes/app_server_spec.rb
output
pdk (INFO): Using Ruby 3.2.2
pdk (INFO): Using Puppet 8.10.0
[✔] Preparing to run the unit tests.
role::app_server
on redhat-9-x86_64
is expected to compile into a catalogue without dependency cycles
is expected to contain Class[profile::base::hardening]
is expected to contain File[/etc/issue.net] with mode => "0644"
Finished in 6.42 seconds (files took 2.18 seconds to load)
3 examples, 0 failures

One compile.with_all_deps test per role is worth more than anything else you will write here. It catches duplicate declarations, missing dependencies, misspelled class names and dependency cycles, and it catches them on a laptop rather than on four hundred machines at 03:00. One practical snag: rspec-puppet needs the profile module and every Forge module on its module path, so the role module wants a .fixtures.yml that symlinks ../profile and lists the Forge modules the profiles pull in. Skip that and the test fails on a missing class instead of a real bug, and you will spend an hour blaming your code. Write the spec file the same hour you write the role. Retrofitting tests onto thirty existing roles is a project; writing one next to a new role is four lines.

The Duplicate Declaration Trap

Here is the failure the whole pattern exists to prevent, and it is worth walking through slowly, because the dangerous version of it does not look like a failure at all. Someone needs one class to run with a non-default value, reaches for the resource-like syntax to pass it, and ships.

site-modules/profile/manifests/database.pp
# The database tier. One line in here is about to break the fleet.
class profile::database {
# "The database team needs root SSH on this tier, only on this tier."
class { 'ssh':
server_options => { 'PermitRootLogin' => 'yes' },
}
include profile::base
include postgresql::server
}

Now watch that one line behave differently under two roles. role::db_server lists profile::base first, so the ssh class is already in the catalog by the time Puppet reaches line 4 of that file. The resource-like declaration collides with it and the catalog refuses to compile. CI catches it, which is the good day.

output
role::db_server
on redhat-9-x86_64
is expected to compile into a catalogue without dependency cycles (FAILED - 1)
Failures:
1) role::db_server on redhat-9-x86_64 is expected to compile into a catalogue without dependency cycles
Failure/Error: it { is_expected.to compile.with_all_deps }
error during compilation: Evaluation Error: Error while evaluating a Resource Statement, Duplicate declaration: Class[Ssh] is already declared at (file: /home/ops/control-repo/site-modules/profile/manifests/base/hardening.pp, line: 5); cannot redeclare (file: /home/ops/control-repo/site-modules/profile/manifests/database.pp, line: 4, column: 3) on node build01.example.com
Finished in 5.11 seconds (files took 2.04 seconds to load)
3 examples, 1 failure

Read the two file locations in that message carefully, because they are not the same kind of thing. The first, after already declared at, is the innocent include ssh inside your hardening profile. The second is the guilty class { 'ssh': }. Through an agent the same text arrives wrapped in a 500 error from the server, but the middle of the message is word for word identical.

Now the bad day. Hand that profile to a role that reaches it before anything else has included ssh, and there is no error whatsoever. The resource-like declaration wins, because an explicit parameter in a class { } declaration outranks Hiera automatic parameter lookup, which in turn outranks the class's own default. Worse, the whole hash is replaced rather than merged, so the two settings nobody was even arguing about, PasswordAuthentication and X11Forwarding, quietly revert to the module's defaults alongside root login. Your common.yaml still says PermitRootLogin: 'no' and still looks authoritative. Code review still shows a hardened baseline. The machine boots with root SSH login enabled. Same two files, same Puppet version, opposite outcomes, decided by which role picked the profile up and in what order. The crash is the friendly result.

One class { } line can outrank your entire Hiera hierarchy
Class parameter values resolve in a fixed order: an explicit value in a resource-like class { } declaration beats Hiera automatic parameter lookup, which beats the default written into the class definition. Hiera cannot override a resource-like declaration, so a hardening value you set once in common.yaml and trust everywhere can be reversed by six characters in a profile nobody re-read. The asymmetry is what hides it. An include after a resource-like declaration is perfectly legal and silent, while a resource-like declaration after an include throws a duplicate declaration error. The same mistake sometimes screams and sometimes says nothing, depending on an evaluation order you do not fully control. Keep every class declaration in role/ and profile/ as include or contain, and push every value that needs to vary into Hiera, at the key the class already reads.

The fix is boring and absolute: profiles and roles never use class { }. One grep holds the line, and it costs nothing to run on every pull request.

terminal
# CI guard: no resource-like class declarations in our own code
grep -rnE '^[[:space:]]*class[[:space:]]*\{' \
site-modules/profile/manifests site-modules/role/manifests
output
site-modules/profile/manifests/database.pp:4: class { 'ssh':

The pattern matches an indented class followed by a brace, which is the resource-like form, and never matches a class definition like class profile::base {, because that one has a name in between. In a pipeline, invert it (! grep -rqE ...) so a match fails the job. It is a crude check, it will never be as clever as a linter, and it has still stopped this exact bug more often than anything clever.

What the Fleet Actually Got

Design is a claim. The node and PuppetDB (the database where the Puppet server keeps every node's facts, catalogs and run reports) are the record. Three commands turn the pattern from a convention into something you can audit. They use jq, the command-line filter for JSON (JavaScript Object Notation, the format PuppetDB answers in), and puppet query, which arrives with the puppetdb_cli gem rather than with Puppet itself.

terminal
# On the node: which of our classes did the last run actually apply?
grep -E '^(role|profile)::' /opt/puppetlabs/puppet/cache/state/classes.txt | sort
# Fleet-wide, in PQL (Puppet Query Language, PuppetDB's own query syntax):
# who has which role, and has anybody ended up with two?
puppet query 'resources[certname, title] { type = "Class" and title ~ "^Role::" }' \
| jq -r '.[] | [.certname, .title] | @tsv' | sort
# And the question that matters most: who never got the baseline at all?
puppet query 'resources[certname] { type = "Class" and title = "Profile::Base::Hardening" }' \
| jq -r '.[].certname' | sort > /tmp/hardened
puppet query 'nodes[certname] { deactivated is null and expired is null }' \
| jq -r '.[].certname' | sort > /tmp/all
comm -23 /tmp/all /tmp/hardened
output
profile::app_deploy
profile::base
profile::base::hardening
profile::base::monitoring
profile::webserver
role::app_server
db01.example.com Role::Database
web01.example.com Role::App_server
web02.example.com Role::App_server
web03.example.com Role::App_server
web03.example.com Role::Legacy_reporting
legacy-nfs-01.example.com

The agent writes classes.txt at the end of every run, which makes it a cheap tripwire: if profile::base::hardening disappears from a host that had it yesterday, a role or a piece of node data changed and the change reached production. The middle query does the same job across the whole fleet, and it found web03 carrying two role classes, so somebody classified that host by hand and it now inherits every future change made to two different machine types. The last query is the one to put on a schedule. legacy-nfs-01 has never received the baseline profile. No banner, no SSH policy, no time sync, and nothing in any dashboard was ever going to tell you.

Roles are a naming convention, not a permission boundary
Nothing in Puppet stops a role from including a profile it should not, and nothing stops a profile from doing anything root can do. What the pattern buys you is a small, predictable set of files where fleet-wide power is concentrated. The actual control is what you wrap around those files: branch protection on the control repository, code owners on site-modules/profile/manifests/base/, and an alert on any commit that touches it. Remember that the Hiera node data counts as code for this purpose. Changing one line of YAML (a plain-text format for lists and key/value data) from role: 'app_server' to role: 'jump_host' repurposes a production machine on its next run, without a single line of Puppet code being edited.

The Honest Trade-Off

Two extra layers cost you something real, and it is worth naming. A value now travels from a YAML file, through a Hiera hierarchy, into a class parameter, into a component module, and every hop is somewhere to lose it. That is exactly why puppet lookup --explain appears in this lesson at all. You will use it constantly, and on a codebase without these layers you would rarely need it. On a fleet of a dozen machines that all do the same job, one shared profile and a node default will serve you better than the full pattern, and choosing that is not laziness.

The failure mode to watch for is role sprawl. Someone needs a web server with Redis on it, writes role::app_server_with_redis, and eighteen months later you have forty roles for forty-five machines and nobody can tell two of them apart. When that starts, make the profiles fatter and the roles fewer, or admit that two machine types are really one type with a Hiera flag inside a profile. Adding an if to the role is the move that feels clever in the moment and quietly ends the pattern.

A practical way in on an existing codebase: find the one class that every node already gets, rename it profile::base, and write a compile.with_all_deps test for the first role you create. Then set code owners on site-modules/profile/manifests/base/ before you write anything else. Both of those are ten minutes of work on day one and a genuine project on day four hundred.

Quick check
01A teammate opens a pull request that adds a file resource and an if $facts['os']['family'] == 'RedHat' block directly to role::app_server. What is the standard objection?
Incorrect — Wrong on the mechanics: facts are readable anywhere in Puppet code, so the objection is about layering, not availability.
Incorrect — This is exactly the drift the pattern exists to stop, and it is how roles turn into unreadable technology dumps.
Correct — keeping roles free of resources and logic is what makes a role readable in ten seconds and testable in one line.
Incorrect — Wrong split: a role names a machine type, and an operating system difference is an implementation detail a profile or Hiera should absorb.
02Suppose profile::webserver had used include nginx rather than contain nginx, and it also declares Class['profile::webserver'] -> Class['profile::app_deploy']. Why might nginx still end up configured after the app deploy runs?
Correct — include adds the class to the catalog, contain also places it inside the declaring class for ordering purposes.
Incorrect — relationships are hard ordering constraints in the catalog, not hints the compiler is free to ignore.
Incorrect — where a module came from has no effect whatsoever on resource ordering.
Incorrect — chaining arrows behave the same regardless of how a class was declared, which is why include-only profiles can still order things.
03CI fails on role::db_server with Duplicate declaration: Class[Ssh] is already declared at (file: .../base/hardening.pp, line: 5); cannot redeclare (file: .../database.pp, line: 4, column: 3). A second role that includes profile::database on its own compiles cleanly. What is happening, and what do you do?
Incorrect — Version skew produces different errors entirely; this message is about two declarations of one class inside one catalog.
Incorrect — That silences one role while leaving the resource-like declaration in place, and strips the baseline off the database tier as a bonus.
Incorrect — Ordering arrows control when resources are applied, not whether a class may be declared twice, so the catalog still fails to compile.
Correct — the clean-compiling role is the more dangerous one, because there the explicit parameter quietly outranks the Hiera hardening value.

Try this

Run tree -L 3 --dirsfirst 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: one class { } line can outrank your entire Hiera hierarchy. 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