Templates (EPP/ERB)
Generate config from data.
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.
Reading An EPP Template
<%- | 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.
$ 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'] }"
# 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.
class myapp (Array[String[1], 1] $allow_cidrs, # at least one entry; see belowInteger[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.
# 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'] }"
Error: lambda: expects a value for parameter 'port'Error: error while rendering eppError: Try 'puppet help epp render' for usageError: lambda: parameter 'port' expects an Integer value, got StringError: error while rendering eppError: Try 'puppet help epp render' for usageError: lambda:has no parameter named 'prot'expects a value for parameter 'port'Error: error while rendering eppError: 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.
# 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 => [] }"
Error: lambda: parameter 'allow_cidrs' expects size to be at least 1, got 0Error: error while rendering eppError: 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.
$ puppet config print strict strict_variables
strict = errorstrict_variables = true
Whitespace Is Where The File Actually Breaks
Drop the trim dashes and the template still renders. It renders wrong.
<%- | Array[String[1]] $users | -%>users:<% $users.each |$u| { %>- name: <%= $u %><% } %>
$ puppet epp render myapp/users.epp --values "{ users => ['alice','bob'] }" | cat -A
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.
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.
# Managed by Puppet (legacy ERB)server {listen <%= @porrt %>;location /admin {<% @allow_cidrs.each do |cidr| -%>allow <%= cidr %>;<% end -%>deny all;}}
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
$ puppet apply /tmp/erb.pp$ head -3 /etc/myapp/legacy.conf
Notice: Compiled catalog for web01.acme.internal in environment production in 0.04 secondsNotice: /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.
$ puppet apply -e 'notice(inline_template("<%= %x(id).strip %>"))'
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 secondsNotice: 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.
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.
<%- | Sensitive[String[1]] $password | -%># Managed by Puppetdb_host = db01.acme.internaldb_password = <%= $password.unwrap %>
$pw = Sensitive('S3cret-2026-Q3') # normally lookup('myapp::db_password') from eyamlfile { '/etc/myapp/db.conf': # rendered content left in the clearensure => file,mode => '0640',content => epp('myapp/db.conf.epp', { 'password' => $pw }),}file { '/etc/myapp/db-safe.conf': # identical bytes on disk, wrapped for the logensure => file,mode => '0640',content => Sensitive(epp('myapp/db.conf.epp', { 'password' => $pw })),}
$ 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
Notice: Compiled catalog for web01.acme.internal in environment production in 0.04 secondsNotice: /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 Puppetdb_host = db01.acme.internal-db_password = S3cret-2026-Q3+db_password = S3cret-2026-Q4Notice: /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.
$ grep -o 'db_password = S3cret-2026-Q[0-9]' \/opt/puppetlabs/puppet/cache/reports/web01.acme.internal/202607211422.yaml
db_password = S3cret-2026-Q3db_password = S3cret-2026-Q4
Prove It Before It Ships
Two checks belong in continuous integration, and one belongs in your own hands before the change reaches production.
$ puppet epp validate templates/*.epp ; echo "EXIT=$?"
Error: Syntax error at end of input (file: templates/admin.conf.epp)Error: Errors while validating eppError: Try 'puppet help epp validate' for usageEXIT=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.
$ 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=$?"
--- 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.
$ puppet apply --noop --show_diff \-e "class { 'myapp': allow_cidrs => ['10.20.0.0/16','192.168.50.7/32'] }"
Notice: Compiled catalog for web01.acme.internal in environment production in 0.04 secondsNotice: /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 eventsNotice: Stage[main]: Would have triggered 'refresh' from 1 eventNotice: 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.
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.