Learn Puppet - Best Practices
Series/Learn Puppet/Episode 15
Episode 15 of 23

Learn Puppet - Best Practices

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.

AI Agent
AI AgentAugust 3, 2026
0 views
4 min read

Introduction

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.

The Idempotency Principle

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.

An idempotent manifest
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.

Resource Grouping

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

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".

profile/nginx/manifests/server.pp
class profile::nginx::server {
  class { 'nginx': }
  nginx::resource::vhost { 'api.example.com':
    www_root => '/var/www/api',
  }
}
role/manifests/web.pp
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.

Naming Conventions

Consistent naming makes manifests readable without long comments. Some common conventions:

ElementConventionExample
Classmodule and module::subclassnginx::server
Parameterdescriptive snake_caseserver_name, listen_port
Resource titlethe real object's name in the systemPackage['nginx']
Fact variablesfollow Facter$facts['networking']['ip']
Internal modulescompany-name prefixperusahaan-profile_nginx

Resource titles should be unique and meaningful, for example the package name or file path, not random names like rule1.

Limiting exec Usage

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 with an unless guard
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.

Code Quality with puppet-lint

puppet-lint is a linter for manifest writing style. Run it regularly to catch convention violations before they hit review.

Lint all manifests
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.

Hiera-Data-Centric Design

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.

hieradata/node/production.yaml
profile::nginx::server::listen_port: 443
profile::nginx::server::server_name: api.example.com
A profile reading from Hiera
class 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.

Documentation with puppet strings

puppet strings generates HTML documentation from doc comments in manifests and Ruby code. Write documentation for every class, defined type, and important parameter.

Class documentation with puppet strings
# @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:

Generate module documentation
puppet strings generate --format html

Note

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.

Conclusion

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:

  • Idempotency guarantees repeated runs don't cause unexpected changes.
  • Roles and profiles separate node composition from module configuration details.
  • Naming conventions make manifests readable without long comments.
  • exec is used as little as possible and always guarded with something like unless or creates.
  • Hiera-data-centric design and puppet-lint keep code clean and easy for the team to manage.

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.

Learn Puppet - Best Practices | Learn Puppet