Building reusable, testable, shareable Puppet code β the right way.
A module is a self-contained bundle of Puppet code that manages a specific piece of infrastructure. Want to manage Apache? There's a module for that. NTP? Module. Your company's custom monitoring stack? You should write a module for that too.
Modules are the primary way to organize Puppet code. They're shareable (via the Puppet Forge), testable, and composable.
Every module follows a standard directory layout:
mymodule/
βββ manifests/
β βββ init.pp β Main class (class mymodule { ... })
β βββ install.pp β Package installation
β βββ config.pp β Configuration management
β βββ service.pp β Service management
βββ files/ β Static files (served via puppet:///modules/mymodule/)
β βββ default.conf
βββ templates/ β Dynamic templates (EPP or ERB)
β βββ config.epp
βββ lib/
β βββ facter/ β Custom facts (Ruby)
β β βββ myapp_version.rb
β βββ puppet/
β βββ functions/ β Custom functions
β βββ types/ β Custom types
βββ data/ β Module-level Hiera data
β βββ common.yaml
β βββ os/
β βββ RedHat.yaml
β βββ Debian.yaml
βββ hiera.yaml β Module-level Hiera config
βββ spec/ β Tests
β βββ spec_helper.rb
β βββ classes/
β βββ init_spec.rb
βββ examples/ β Usage examples
β βββ init.pp
βββ CHANGELOG.md
βββ README.md
βββ metadata.json β Module metadata
Module names follow the pattern author-modulename. The directory on disk is just modulename (without the author prefix). Class names map directly to file paths:
| Class Name | File Path |
|---|---|
mymodule |
manifests/init.pp |
mymodule::install |
manifests/install.pp |
mymodule::config |
manifests/config.pp |
mymodule::config::ssl |
manifests/config/ssl.pp |
The most widely-used pattern for organizing Puppet code at scale. If you learn one pattern from this guide, make it this one.
Profiles are technology-specific wrappers around modules. They configure a single piece of technology:
# site-modules/profile/manifests/webserver.pp
class profile::webserver (
Integer $port = 80,
Boolean $ssl = false,
) {
class { 'apache':
default_vhost => false,
mpm_module => 'prefork',
}
apache::vhost { $facts['networking']['fqdn']:
port => $port,
docroot => '/var/www/html',
ssl => $ssl,
}
if $ssl {
include apache::mod::ssl
}
}
# site-modules/profile/manifests/database.pp
class profile::database (
String $root_password = lookup('profile::database::root_password'),
) {
class { 'mysql::server':
root_password => $root_password,
override_options => {
'mysqld' => {
'max_connections' => 200,
'bind-address' => '0.0.0.0',
},
},
}
}
# site-modules/profile/manifests/base.pp
class profile::base {
include ntp
include ssh
include firewall
include monitoring
}Roles define what a server IS by composing profiles. Each server gets exactly one role. A role should be nothing but include statements:
# site-modules/role/manifests/webserver.pp
class role::webserver {
include profile::base
include profile::webserver
include profile::monitoring
}
# site-modules/role/manifests/database.pp
class role::database {
include profile::base
include profile::database
include profile::monitoring
include profile::backup
}
# site-modules/role/manifests/app_server.pp
class role::app_server {
include profile::base
include profile::webserver
include profile::database
include profile::app
}In site.pp or via Hiera/ENC:
# manifests/site.pp
node default {
include role::base
}
node /^web\d+/ {
include role::webserver
}
node /^db\d+/ {
include role::database
}Or via Hiera (cleaner for large fleets):
# data/nodes/web01.example.com.yaml
classes:
- role::webserver# manifests/site.pp
lookup('classes', Array[String], 'unique', []).includeBest Practice: For fleets larger than ~10 nodes, avoid regex node definitions in
site.pp. Use Hiera or an ENC (External Node Classifier) to assign roles. This keeps yoursite.ppclean and makes role assignments data-driven. Also, remember: one role per node. Roles compose profiles, which compose modules. This hierarchy keeps your code maintainable and your mental model clear.
Custom facts extend Facter with organization-specific information.
# lib/facter/myapp_version.rb
Facter.add(:myapp_version) do
confine kernel: 'Linux'
setcode do
if File.exist?('/opt/myapp/VERSION')
File.read('/opt/myapp/VERSION').strip
else
'not installed'
end
end
end# lib/facter/myapp_info.rb
Facter.add(:myapp_info) do
setcode do
result = {}
if File.exist?('/opt/myapp/VERSION')
result['version'] = File.read('/opt/myapp/VERSION').strip
result['installed'] = true
result['config_dir'] = '/etc/myapp'
else
result['installed'] = false
end
result
end
endAccess in Puppet: $facts['myapp_info']['version']
Drop a script or YAML file in /etc/puppetlabs/facter/facts.d/:
# /etc/puppetlabs/facter/facts.d/datacenter.yaml
---
datacenter: us-east-1
rack: A42
environment_type: productionOr a script:
#!/bin/bash
# /etc/puppetlabs/facter/facts.d/hardware.sh
echo "chassis_type=$(dmidecode -s chassis-type 2>/dev/null || echo unknown)"
echo "bios_vendor=$(dmidecode -s bios-vendor 2>/dev/null || echo unknown)"Every module needs a metadata.json:
{
"name": "myorg-webserver",
"version": "1.0.0",
"author": "My Organization",
"summary": "Manages Apache web server configuration",
"license": "Apache-2.0",
"source": "https://github.com/myorg/puppet-webserver",
"dependencies": [
{ "name": "puppetlabs-apache", "version_requirement": ">= 8.0.0 < 13.0.0" },
{ "name": "puppetlabs-stdlib", "version_requirement": ">= 8.0.0 < 10.0.0" }
],
"operatingsystem_support": [
{ "operatingsystem": "RedHat", "operatingsystemrelease": ["8", "9", "10"] },
{ "operatingsystem": "Debian", "operatingsystemrelease": ["11", "12"] },
{ "operatingsystem": "Ubuntu", "operatingsystemrelease": ["22.04", "24.04"] }
],
"requirements": [
{ "name": "puppet", "version_requirement": ">= 7.0.0 < 9.0.0" }
]
}# spec/classes/init_spec.rb
require 'spec_helper'
describe 'webserver' do
on_supported_os.each do |os, os_facts|
context "on #{os}" do
let(:facts) { os_facts }
it { is_expected.to compile.with_all_deps }
it { is_expected.to contain_package('httpd') }
it { is_expected.to contain_service('httpd').with_ensure('running') }
context 'with ssl enabled' do
let(:params) { { ssl: true } }
it { is_expected.to contain_class('apache::mod::ssl') }
end
context 'with custom port' do
let(:params) { { port: 8080 } }
it { is_expected.to contain_apache__vhost(os_facts[:fqdn]).with_port(8080) }
end
end
end
end# Install test dependencies
bundle install
# Run unit tests
bundle exec rake spec
# Run a specific test file
bundle exec rspec spec/classes/init_spec.rb
# Validate syntax
bundle exec rake validate
# Run lint checks
bundle exec rake lintopenvox-lint is the community linter for OpenVox/Puppet code. It checks for:
- Style guide violations (indentation, quoting, arrow alignment)
- Legacy fact usage (
$osfamilyβ$facts['os']['family']) - Deprecated Hiera 3 functions (
hiera()βlookup()) - Common anti-patterns
# Install
gem install openvox-lint
# Lint a single file
openvox-lint manifests/init.pp
# Lint an entire module
openvox-lint .
# Auto-fix what can be fixed
openvox-lint --fix .Pro tip: Add openvox-lint to your CI/CD pipeline to catch issues before they reach production.
For integration testing against real systems:
# Provision a test node
bundle exec rake 'litmus:provision[docker, litmusimage/centos:9]'
# Install the module on the test node
bundle exec rake litmus:install_agent
bundle exec rake litmus:install_module
# Run acceptance tests
bundle exec rake litmus:acceptance:parallel
# Tear down
bundle exec rake litmus:tear_down# Build the module package
puppet module build
# This creates a .tar.gz in the pkg/ directory
# Upload it to https://forge.puppet.com/uploadOr use the PDK (Puppet Development Kit):
# Create a new module from templates
pdk new module myorg-newmodule
# Create a new class
pdk new class install
# Validate the module
pdk validate
# Run unit tests
pdk test unit
# Build for publishing
pdk build- Follow the roles and profiles pattern
- Use Hiera for all parameter data (not hardcoded values)
- Include type validation for all class parameters
- Write unit tests for every class and defined type
- Use EPP templates (not ERB) for new code
- Include a clear README.md with usage examples
- Maintain a CHANGELOG.md
- Pin module dependencies in
metadata.json - Use
containinstead ofincludefor classes that need ordering - Never use
execwhen a proper resource type exists
Next up: Orchestration β
This document was created with the assistance of AI (Grok, xAI). All technical content has been reviewed and verified by human contributors.