CoursesPuppetTemplates (EPP/ERB)

Templates (EPP/ERB)

Generate config from data.

Intermediate12 min · lesson 7 of 12

A rubber stamp with gaps in it. The border, the wording and the boxes get cut once, and the only thing that changes between one impression and the next is what somebody writes into the gaps. A Puppet template is that stamp for a configuration file. You keep the exact text the daemon expects, nginx.conf or sshd_config or an application's YAML (YAML Ain't Markup Language, the indentation-sensitive text format that half your app configs are written in), and you mark the handful of spots where the value differs from one machine to the next.

What comes back out is a string. That word carries more weight than it looks like it does. A template writes nothing to disk on its own. It produces text, and something else has to place that text, which in practice is always the content attribute of a file resource. Puppet 8 ships two languages for the marking-up part. EPP (Embedded Puppet, the Puppet language itself sitting inside an ordinary text file) is the native one, and it is what you should write today. ERB (Embedded Ruby, the same idea with Ruby code inside the tags) is the older one, and you will meet it the first time you open a module somebody published on the Puppet Forge (the public registry of shared Puppet modules) back in 2016.

Where The Rendering Actually Happens

The template is a recipe that never leaves the kitchen. Only the finished plate goes out to the table. Rendering runs at catalog compile time, on the compiling side, never on the machine you are configuring. With a primary server (the central Puppet Server that agents check in to) the work happens there, inside the server process. With puppet apply on a standalone box, the kitchen and the table are the same machine. Either way the agent receives a catalog, which is the finished node-specific list of resources, and inside that catalog the file resource already carries the whole rendered text as one flat string. The node never receives your .epp file. It never sees the loop, the Hiera lookup (Hiera 5 is Puppet's data layer, the stack of YAML files your class parameters get filled in from), or the fact you branched on.

That split tells you who has to be trusted. Everything a template reads is read on the compiler, the machine holding the control repository (the Git repository carrying your manifests and environments), the Hiera data and the eyaml keys (hiera-eyaml is the Hiera backend that keeps encrypted values inside those data files). Everything a template produces is copied into the catalog, cached on the node as JSON (JavaScript Object Notation, the plain-text data format Puppet moves catalogs around in) under /opt/puppetlabs/puppet/cache/client_data/catalog/, stored in PuppetDB (the fleet-wide database of catalogs, facts and reports) if you run one, and quoted back inside the run report. Render a database password and it now lives in four places. One of them is the file you meant to write.

Where a rendered byte travels
1Data on the compiler
facts, class params, Hiera, eyaml
2epp() renders
template collapses into one flat String
3String enters the catalog
as the file resource's content parameter
4Catalog crosses the network
over an encrypted link, then cached on the node
5file resource writes it
owner, group and mode are applied here
6Report travels back
carrying the diff, unless you marked it Sensitive
The .epp file never leaves the compiler. The rendered text goes everywhere the catalog and the report go.

Reading An EPP Template

modules/myapp/templates/admin.conf.epp
<%- | String[1] $servername,
Integer[1,65535] $port,
Array[String[1]] $allow_cidrs,
| -%>
# Managed by Puppet. Local edits are erased on the next run.
server {
listen <%= $port %>;
server_name <%= $servername %>;
location /admin {
<% $allow_cidrs.each |$cidr| { -%>
allow <%= $cidr %>;
<% } -%>
deny all;
}
}

Five tag shapes cover everything you will ever write. <%= $expr %> evaluates a Puppet expression and prints the result. <% ... %> runs Puppet code and prints nothing, which is how the each loop gets in. <%# ... %> is a comment that never reaches the output, handy for leaving a note to the next engineer without leaving it in production. <%% prints a literal <% , for the day you have to template a file that itself contains template tags. And the first tag in the file is special: <%- | ... | -%> declares the parameters with their types, and it has to come ahead of any other tag or text.

The dashes are whitespace controls. A closing -%> swallows the newline that follows the tag. An opening <%- swallows the whitespace sitting in front of it on that line. Leave them off and every line that holds nothing but control flow leaves a blank line behind in the finished file. Hold that thought. It is the most common way a template that reads perfectly well produces a file a daemon rejects.

terminal
$ puppet epp render myapp/admin.conf.epp \
--values "{ servername => 'web01.acme.internal',
port => 8080,
allow_cidrs => ['10.20.0.0/16', '192.168.50.7/32'] }"
output
# Managed by Puppet. Local edits are erased on the next run.
server {
listen 8080;
server_name web01.acme.internal;
location /admin {
allow 10.20.0.0/16;
allow 192.168.50.7/32;
deny all;
}
}

myapp/admin.conf.epp is the module form. The part before the slash is the module name, the rest is a path under that module's templates/ directory, and it is the identical string you hand to epp() in a manifest. So this command exercises the real thing rather than an approximation of it. --values takes a Puppet-language hash typed on the command line. --values_file reads the same hash out of a .pp or .yaml file, which is far nicer to keep in the repository next to your tests. --facts loads a facts file, the YAML or JSON that puppet facts show produces, so a template reading $facts renders the way it would on one specific node.

modules/myapp/manifests/init.pp
class myapp (
Array[String[1], 1] $allow_cidrs, # at least one entry; see below
Integer[1,65535] $port = 8080,
) {
file { '/etc/myapp/admin.conf':
ensure => file,
owner => 'root',
group => 'root',
mode => '0640',
content => epp('myapp/admin.conf.epp', {
'servername' => $facts['networking']['fqdn'],
'port' => $port,
'allow_cidrs' => $allow_cidrs,
}),
# in a real class you would also declare the service and add:
# notify => Service['nginx'],
}
}

Set mode explicitly. Say nothing about permissions and Puppet does not manage them at all: an existing file keeps whatever it already had, and a brand-new one gets whatever default the writing process lands on. An admin allowlist sitting at 0644 hands every local account on the box the exact list of networks that are allowed through the front door. That is reconnaissance you gave away for free, and nothing in a syntax check is going to flag it.

The Parameter Tag Is A Contract That Gets Checked

Puppet turns an EPP template into a lambda, which is the language's word for an anonymous function: a block of code with named inputs and no name of its own. The parameter tag is that function's signature. Call it wrong and compilation stops instead of producing something plausible. Three ways of getting it wrong are worth seeing once, because the error text tells you exactly which one you hit.

terminal
# 1. forget a parameter entirely
$ puppet epp render myapp/admin.conf.epp \
--values "{ servername => 'web01.acme.internal', allow_cidrs => ['10.20.0.0/16'] }"
# 2. pass the port as a string instead of an integer
$ puppet epp render myapp/admin.conf.epp \
--values "{ servername => 'web01.acme.internal', port => '8080', allow_cidrs => ['10.20.0.0/16'] }"
# 3. misspell a key in the hash
$ puppet epp render myapp/admin.conf.epp \
--values "{ servername => 'web01.acme.internal', prot => 8080, allow_cidrs => ['10.20.0.0/16'] }"
output
Error: lambda: expects a value for parameter 'port'
Error: error while rendering epp
Error: Try 'puppet help epp render' for usage
Error: lambda: parameter 'port' expects an Integer value, got String
Error: error while rendering epp
Error: Try 'puppet help epp render' for usage
Error: lambda:
has no parameter named 'prot'
expects a value for parameter 'port'
Error: error while rendering epp
Error: Try 'puppet help epp render' for usage

The word lambda in those messages is the template itself, so read it as the name of the file you were rendering. The third case earns its keep: one misspelled key produces both halves of the complaint at once, the name Puppet did not recognise and the parameter that consequently has no value. That pair of facts is everything you need to fix a typo in five seconds instead of squinting at a hash.

Types buy you more than typo protection. Array[String[1]] is perfectly happy with an empty array, and an empty array through that each loop renders zero allow lines. The template above survives that, because the deny all; underneath the loop is doing the real work. Delete that one line, as plenty of real templates do, and an empty allowlist renders a location block carrying no restriction at all, which nginx reads as permit everyone. The file is valid. The syntax check passes. The admin endpoint is open to whoever finds it. Writing the type as Array[String[1], 1] puts a floor of one element under the parameter and turns that silent outcome into a loud compile failure.

terminal
# with the parameter tightened to Array[String[1], 1] $allow_cidrs
$ puppet epp render myapp/admin.conf.epp \
--values "{ servername => 'web01.acme.internal', port => 8080, allow_cidrs => [] }"
output
Error: lambda: parameter 'allow_cidrs' expects size to be at least 1, got 0
Error: error while rendering epp
Error: Try 'puppet help epp render' for usage

Puppet 8 changed two defaults that used to make all of this worse. strict_variables is on out of the box, so referencing a variable nobody defined is an evaluation error rather than a silent empty string, and strict is set to error instead of warning. That matters here because of how EPP sees data. A template rendered by epp() runs against global scope only. It gets its own parameters, global data such as $facts and $trusted, and fully qualified names like $myapp::port, and nothing else. Reach for a local variable belonging to the class that called it and Puppet 8 stops with Unknown variable: 'local_var'. Under Puppet 7 defaults the same template quietly rendered nothing in that spot. Its cousin inline_epp(), which takes the template text as a string inside the manifest, does see the caller's local variables, so an identical snippet can behave differently depending on which function renders it. If you run OpenVox, the community fork of Puppet 8 maintained by Vox Pupuli, every detail in this lesson behaves the same way.

terminal
$ puppet config print strict strict_variables
output
strict = error
strict_variables = true

Whitespace Is Where The File Actually Breaks

Drop the trim dashes and the template still renders. It renders wrong.

modules/myapp/templates/users.epp
<%- | Array[String[1]] $users | -%>
users:
<% $users.each |$u| { %>
- name: <%= $u %>
<% } %>
terminal
$ puppet epp render myapp/users.epp --values "{ users => ['alice','bob'] }" | cat -A
output
users:$
$
- name: alice$
$
- name: bob$
$

cat -A marks every line ending with a $, which is how you see what your editor hides from you. The two control-flow lines each left their newline behind, so the file now carries a blank line between every entry. nginx would shrug at that. A systemd unit file or an /etc/hosts fragment will not, and YAML breaks in ways that surface hours later inside an application nobody was touching. Put the dash back on both tags, <% $users.each |$u| { -%> and <% } -%>, and the gaps disappear.

Validation proves the grammar, not the file
puppet epp validate parses the template and stops there. Whitespace is legal EPP, so a template with every trim dash missing validates clean and exits 0. So does a loop over an empty array, a CIDR block (Classless Inter-Domain Routing, the 10.20.0.0/16 way of writing a range of addresses) with the wrong octet, and an allowlist you forgot to terminate with deny all. Nothing in the syntax check knows what the file is for. The only test that catches these is rendering with realistic values and reading the output with your own eyes, ideally against a known-good copy committed in the repository. Render both sides of every conditional too, because the branch you never exercised is the one that ships broken.

ERB Runs Ruby Inside Your Compiler

ERB looks close enough to EPP to fool you. Same angle brackets, same dashes. The difference is what runs inside the tags and how the template gets hold of data. An ERB template has no parameter list at all. It reads the calling class's variables as Ruby instance variables, so $port in the manifest becomes @port in the template, and anything outside that scope arrives through a helper object as scope['myapp::port']. Convenient, and completely invisible from the call site, which is the reason EPP exists.

modules/myapp/templates/legacy.erb
# Managed by Puppet (legacy ERB)
server {
listen <%= @porrt %>;
location /admin {
<% @allow_cidrs.each do |cidr| -%>
allow <%= cidr %>;
<% end -%>
deny all;
}
}
/tmp/erb.pp
class erbdemo {
$port = 8080
$allow_cidrs = ['10.20.0.0/16', '192.168.50.7/32']
file { '/etc/myapp/legacy.conf':
ensure => file,
mode => '0640',
content => template('myapp/legacy.erb'), # nothing is passed in
}
}
include erbdemo
terminal
$ puppet apply /tmp/erb.pp
$ head -3 /etc/myapp/legacy.conf
output
Notice: Compiled catalog for web01.acme.internal in environment production in 0.04 seconds
Notice: /Stage[main]/Erbdemo/File[/etc/myapp/legacy.conf]/ensure: defined content as '{sha256}0567947ee9890dc145920757ba22d801e98d57c66b7a84c4a10a5ba97ce2c525'
Notice: Applied catalog in 0.02 seconds
# Managed by Puppet (legacy ERB)
server {
listen ;

@porrt is a typo. Ruby has no instance variable by that name, so it evaluates to nil (Ruby's word for nothing at all), and nil renders as an empty string. Puppet reports a clean run. The file lands. nginx gets listen ; and you find out at the next reload, or worse, at an unrelated restart three weeks later when nobody is thinking about Puppet. The same typo in an EPP template stops the compile with a named error before a single byte is written.

terminal
$ puppet apply -e 'notice(inline_template("<%= %x(id).strip %>"))'
output
Notice: Scope(Class[main]): uid=0(root) gid=0(root) groups=0(root)
Notice: Compiled catalog for web01.acme.internal in environment production in 0.01 seconds
Notice: Applied catalog in 0.01 seconds

Read the ordering in that output. The shell command ran and printed its result before the catalog had finished compiling, because ERB tags hold real Ruby that executes inside the compiler process with that process's privileges. Under puppet apply it ran as root. On a primary server it runs as the puppet user inside Puppet Server, on the machine that holds every node's Hiera data and the certificate authority. An ERB template inside a Forge module you installed without reading is code execution on that machine, at compile time, once for every node that includes the class. EPP offers no inline Ruby. It evaluates Puppet expressions and functions, which is a far smaller surface, although a module can still ship a custom Ruby function that an EPP template calls. Smaller, not zero.

Read the templates/ directory before you trust a module
puppet module install and an unpinned Puppetfile entry both pull Ruby you have never read onto the machine that compiles catalogs for your whole fleet. Grep every new or upgraded module for .erb files and for inline_template, and read what sits inside the tags. Pin exact versions, review the diff on every version bump, and prefer modules whose templates are .epp. This is the same supply-chain question you would ask of an npm package, with one difference that changes the answer: the code runs on your primary server, not in a throwaway build container.

The Rendered File Is Never The Only Copy

Templates that render secrets need one extra move. The rendered string is a resource parameter, so it rides inside the catalog, and when the file changes the before-and-after text shows up in the run report. Sensitive is Puppet's sealed envelope. The contents still get delivered, but nobody handling the envelope can read them off the outside. Wrap the value going in, unwrap it inside the template, and wrap the finished string coming back out.

modules/myapp/templates/db.conf.epp
<%- | Sensitive[String[1]] $password | -%>
# Managed by Puppet
db_host = db01.acme.internal
db_password = <%= $password.unwrap %>
/tmp/db.pp
$pw = Sensitive('S3cret-2026-Q3') # normally lookup('myapp::db_password') from eyaml
file { '/etc/myapp/db.conf': # rendered content left in the clear
ensure => file,
mode => '0640',
content => epp('myapp/db.conf.epp', { 'password' => $pw }),
}
file { '/etc/myapp/db-safe.conf': # identical bytes on disk, wrapped for the log
ensure => file,
mode => '0640',
content => Sensitive(epp('myapp/db.conf.epp', { 'password' => $pw })),
}
terminal
$ puppet apply /tmp/db.pp # first run: both files created
$ sed -i 's/2026-Q3/2026-Q4/' /tmp/db.pp # rotate the password
$ puppet apply --show_diff /tmp/db.pp
output
Notice: Compiled catalog for web01.acme.internal in environment production in 0.04 seconds
Notice: /Stage[main]/Main/File[/etc/myapp/db.conf]/content:
--- /etc/myapp/db.conf 2026-07-21 14:22:18.673636423 +0000
+++ /tmp/puppet-file20260721-2035-vu0z8q 2026-07-21 14:22:20.453736635 +0000
@@ -1,3 +1,3 @@
# Managed by Puppet
db_host = db01.acme.internal
-db_password = S3cret-2026-Q3
+db_password = S3cret-2026-Q4
Notice: /Stage[main]/Main/File[/etc/myapp/db.conf]/content: content changed '{sha256}b0753f8757e494c621a45687e8e8b02543544a07a4cc35b2f76825cee923baa8' to '{sha256}3458bfe21f254031196ed491f6c0543e6a67d7f88230af32da3d646089d06046'
Notice: /Stage[main]/Main/File[/etc/myapp/db-safe.conf]/content: [diff redacted]
Notice: /Stage[main]/Main/File[/etc/myapp/db-safe.conf]/content: changed [redacted] to [redacted]
Notice: Applied catalog in 0.03 seconds

Both files on disk are byte for byte identical, password and all. Everything downstream of them is not. And the terminal is the smaller half of the problem, because Puppet keeps a copy of that report on the node. The default report handler is store, and it applies to puppet apply exactly as it does to a server run.

terminal
$ grep -o 'db_password = S3cret-2026-Q[0-9]' \
/opt/puppetlabs/puppet/cache/reports/web01.acme.internal/202607211422.yaml
output
db_password = S3cret-2026-Q3
db_password = S3cret-2026-Q4
A rotated secret leaves a trail of every previous value
Both the old password and the new one are sitting in that report in plain text, because a diff carries both sides of the change. The store handler never tidies up after itself, so those files pile up one per run under reportdir until somebody prunes them. On a real installation the same report also travels to the primary server and into PuppetDB, where it is queryable, and PuppetDB's report-ttl (14 days by default) is the only thing aging that copy out. Rotate quarterly without Sensitive and anyone who can read the directory or run a query recovers your whole rotation history. Sensitive costs one function call and removes the entire class of leak. Be clear about its limit though: it hides the value from logs, diffs and reports, and it does nothing whatsoever about the file on disk. That part is the job of mode => '0640' and a real owner.

Prove It Before It Ships

Two checks belong in continuous integration, and one belongs in your own hands before the change reaches production.

terminal
$ puppet epp validate templates/*.epp ; echo "EXIT=$?"
output
Error: Syntax error at end of input (file: templates/admin.conf.epp)
Error: Errors while validating epp
Error: Try 'puppet help epp validate' for usage
EXIT=1

That is a missing <% } -%>. EPP has no idea where the loop was meant to close, so it runs out of file and says so, and because there is no leftover token to point at you get a filename with no line number. Exit code 1 fails the job, which is the whole point of putting it in a pipeline. Two details save you a second pass. puppet epp validate stops after the first file that reports errors unless you add --continue_on_error, and if you use PDK (the Puppet Development Kit, the scaffolding and test harness for modules) then plain pdk validate already runs this for you: its puppet-epp validator shells out to puppet epp validate across every **/*.epp in the module, alongside puppet-lint and the metadata check.

The second check pins the output rather than the grammar. Render against a fixture and compare the result with a golden file committed in the repository, so any change to a rendered config has to be a change somebody meant to make.

terminal
$ puppet epp render myapp/admin.conf.epp \
--values_file spec/fixtures/web01.yaml > /tmp/rendered.out
$ diff -u spec/fixtures/web01.conf.golden /tmp/rendered.out ; echo "EXIT=$?"
output
--- spec/fixtures/web01.conf.golden 2026-07-21 14:18:08.082262637 +0000
+++ /tmp/rendered.out 2026-07-21 14:23:22.206436342 +0000
@@ -5,6 +5,7 @@
location /admin {
allow 10.20.0.0/16;
+ allow 192.168.50.7/32;
deny all;
}
}
EXIT=1

The last check is the run itself. --noop (short for no operation, a dry run) compiles everything and reports what it would have done without touching the node, and --show_diff prints the difference between the file living there now and the text your template produced for it.

terminal
$ puppet apply --noop --show_diff \
-e "class { 'myapp': allow_cidrs => ['10.20.0.0/16','192.168.50.7/32'] }"
output
Notice: Compiled catalog for web01.acme.internal in environment production in 0.04 seconds
Notice: /Stage[main]/Myapp/File[/etc/myapp/admin.conf]/content:
--- /etc/myapp/admin.conf 2026-07-21 14:18:08.082262637 +0000
+++ /tmp/puppet-file20260721-29-zrt0fg 2026-07-21 14:18:09.946369626 +0000
@@ -5,6 +5,7 @@
location /admin {
allow 10.20.0.0/16;
+ allow 192.168.50.7/32;
deny all;
}
}
Notice: /Stage[main]/Myapp/File[/etc/myapp/admin.conf]/content: content changed '{sha256}165f54e2c86182e307c11f7cd6486e0c695dbfb24911b32a0f6c30938e34fd74' to '{sha256}62f238f5d7eb6ff88e1f7788e738f0c42169634d080cb44f7e6a06972a4d1094' (noop)
Notice: /Stage[main]/Myapp/File[/etc/myapp/admin.conf]/mode: mode changed '0644' to '0640' (noop)
Notice: Class[Myapp]: Would have triggered 'refresh' from 2 events
Notice: Stage[main]: Would have triggered 'refresh' from 1 event
Notice: Applied catalog in 0.02 seconds

Read the mode line as carefully as you read the diff. mode changed '0644' to '0640' (noop) is telling you that allowlist has been world-readable on that node until right now, which is a finding worth writing down separately from the change you came to make. On an agent the same dress rehearsal is puppet agent -t --noop --show_diff. The global show_diff setting is off by default, so you have to ask for it every time, and asking for it pushes file contents through the log and into the report. That is precisely why Sensitive belongs on anything you would not want printed while you look.

Quick check
01Where does an EPP template get rendered, and what actually reaches the node?
Incorrect — The node never receives the template at all; rendering is finished before the catalog is sent.
Correct — That is also why the compiler is the machine that sees every secret a template touches.
Incorrect — No such rule exists, and ensure has nothing to do with where rendering happens.
Incorrect — Both languages render at compile time on the compiling side; ERB reads compiler state, never node state.
02A template's control-flow lines end with %> instead of -%>. puppet epp validate exits 0 and the render succeeds. What has actually changed?
Incorrect — The dashes change the rendered bytes, which is the only reason they exist.
Incorrect — Trim dashes and parameter type-checking are unrelated mechanisms.
Correct — Whitespace is legal EPP, so validate passes while the file quietly gains gaps.
Incorrect — The file extension picks the engine; trim dashes never change which parser runs.
03An nginx reload fails on one node. The rendered config contains the line 'listen ;' and the last Puppet run reported no errors and no warnings at all. The template is a .erb file inherited from an old Forge module. What happened, and where do you look first?
Incorrect — Puppet writes the file atomically from a fully rendered string, so a half-written value is not a transport failure.
Incorrect — ERB templates have no parameter tag and no types, so there is nothing there to reject a value or default it.
Incorrect — strict_variables governs Puppet variables; an @-prefixed name in ERB is a Ruby instance variable and sits outside its reach.
Correct — Undefined Ruby instance variables are nil, nil renders as nothing, and Puppet reports a perfectly successful run.

Try this

Run port => 8080, 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: validation proves the grammar, not the file. 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