Apply production-safe Puppet manifest best practices: idempotency, resource grouping, the roles and profiles pattern, naming conventions, and limiting exec. Keep quality high with puppet-lint, hiera-data-centric design, and documentation.

In episode 14 you learned to search for and install modules from Puppet Forge and to develop your own modules with PDK. In this episode we'll discuss the rules of the game that separate manifests that merely work from manifests that are safe to operate in production: best practices.
Best practices aren't just a writing style. Idempotent, well-grouped manifests free of exec side effects will save your team from 2 AM incidents. The roles and profiles pattern is the industry standard, and tools like puppet-lint and puppet strings keep quality consistent over time.
In this episode we'll cover idempotency, resource grouping, the roles and profiles pattern, naming conventions, limiting exec usage, the hiera-data-centric principle, and automatic documentation with puppet strings.
A manifest is idempotent if running it repeatedly produces the same result, without extra side effects. Puppet resources are designed for this: package, service, file, and user compare the current state with the desired state, then only change what differs.
package { 'nginx':
ensure => installed,
}
service { 'nginx':
ensure => running,
enable => true,
}
file { '/etc/nginx/nginx.conf':
ensure => file,
source => 'puppet:///modules/nginx/nginx.conf',
owner => 'root',
group => 'root',
mode => '0644',
require => Package['nginx'],
notify => Service['nginx'],
}Run puppet apply twice in a row; the second run should show noop status or no changes. If a resource keeps changing on every run, that's a sign the manifest isn't idempotent.
Resources should be grouped by responsibility, not mixed randomly in one file. A class with a clear focus is easier to test, maintain, and reuse. The nginx class handles installation and basic configuration, while vhost configuration can be split into its own class.
Tip
When a class grows beyond one screen, break it into child classes. For example: nginx::install, nginx::config, and nginx::service, all included from nginx::init. This also makes per-part unit testing easier.
The roles and profiles pattern is the two-layer separation most recommended by the Puppet community. Profiles wrap modules into meaningful configuration for a technical role, while roles combine several profiles into a single node "identity".
class profile::nginx::server {
class { 'nginx': }
nginx::resource::vhost { 'api.example.com':
www_root => '/var/www/api',
}
}class role::web {
include profile::base
include profile::nginx::server
include profile::php::fpm
}A node in hiera only needs to assign one role, for example role::web, and the role handles the whole composition. Profiles contain concrete modules with real parameters, while roles never contain configuration details.
Important
The golden rule of this pattern: roles only include profiles, and profiles only use modules. Don't put detailed configuration directly in roles, and don't include roles from other roles. This discipline keeps the hierarchy shallow and easy to understand.
Consistent naming makes manifests readable without long comments. Some common conventions:
| Element | Convention | Example |
|---|---|---|
| Class | module and module::subclass | nginx::server |
| Parameter | descriptive snake_case | server_name, listen_port |
| Resource title | the real object's name in the system | Package['nginx'] |
| Fact variables | follow Facter | $facts['networking']['ip'] |
| Internal modules | company-name prefix | perusahaan-profile_nginx |
Resource titles should be unique and meaningful, for example the package name or file path, not random names like rule1.
The exec resource is an escape hatch that's often abused. Every exec can change the system outside Puppet's declarative model, so it should be used as little as possible. If you must, always give it a guard to stay idempotent: creates, unless, or onlyif.
exec { 'initialise-db':
command => '/usr/local/bin/init-db.sh',
unless => '/usr/local/bin/check-db-initialised.sh',
path => ['/usr/local/bin', '/usr/bin'],
}Notice that the exec only runs when the unless guard returns a non-zero status, so repeated runs don't run the initialization twice.
Caution
Before writing an exec, ask first: is there a built-in Puppet resource that can replace it? Package installation uses package, files use file, service restarts use service with notify. exec is only for things that truly can't be declared.
puppet-lint is a linter for manifest writing style. Run it regularly to catch convention violations before they hit review.
puppet-lint manifests/Reports like quoted_strings_not_needed or right_to_left_relationship help enforce consistency. You can ignore certain rules, for example arrow_on_right_operand_line, via a .puppet-lint.rc file with --no-arrow_on_right_operand_line-check, but don't disable security rules carelessly.
Manifests should contain little data and lots of logic. Separate values that change between environments into Hiera instead of embedding them as class defaults. That way, staging vs production differences are handled just in hieradata files.
profile::nginx::server::listen_port: 443
profile::nginx::server::server_name: api.example.comclass profile::nginx::server (
Integer $listen_port = 80,
String $server_name = 'localhost',
) {
...
}Class parameters are automatically filled from Hiera as long as the hiera keys match the parameter names, for example profile::nginx::server::listen_port. This principle keeps data that should be the operations team's responsibility from spreading through the code.
puppet strings generates HTML documentation from doc comments in manifests and Ruby code. Write documentation for every class, defined type, and important parameter.
# @summary Mengelola instalasi dan konfigurasi Nginx.
#
# @param listen_port Port yang dipakai Nginx untuk listening.
# @param server_name Nama domain virtual host utama.
class profile::nginx::server (
Integer $listen_port = 80,
String $server_name = 'localhost',
) {Generate the documentation with puppet strings generate:
puppet strings generate --format htmlNote
Don't forget the module README. A README that explains parameters and usage examples helps new team members understand the module without reading all the code. For public modules, a good README even increases user trust on the Forge.
In this episode 15 you understood that good production manifests are built on idempotency, clear resource grouping, the roles and profiles pattern, consistent naming conventions, and highly restricted exec usage. We also learned to keep quality high with puppet-lint, separate data into Hiera, and document code with puppet strings.
Key takeaways:
unless or creates.All those patterns run on Puppet's built-in resources. But sometimes the need goes beyond standard resources. In episode 16 we'll cover Learn Puppet - Custom Resources, Functions & Providers: defined types, custom facts for Facter, functions with the modern functions API, and creating custom providers per platform.