CoursesChefTemplates & files

Templates & files

ERB templates from node data.

Intermediate12 min · lesson 6 of 12

A rental agreement gets printed once, with blanks where the tenant's name, the rent and the start date go. One document, a thousand tenancies. A Chef template is that document, for config files. You write nginx.conf.erb once, leave blanks where the worker count and the backend list belong, and Chef fills them from each machine's own data while the run is happening. A two-core VM (virtual machine, a whole computer running as software on a shared physical host) ends up with worker_processes 2; on disk. A thirty-two-core box gets worker_processes 32;. One file in your repo, a different file on every node.

Two resources put files on a node, and choosing between them is a security decision rather than a style one. template renders ERB (Embedded Ruby, ordinary text with small pieces of Ruby code tucked inside it) and writes the result. cookbook_file copies bytes across untouched and evaluates nothing. One of those runs code as root on every machine that converges it. The other cannot.

The Blanks Get Filled In On The Node, As Root

If you have used Ansible, unlearn one thing first. Ansible renders its templates on the control machine and ships finished text over the wire. Chef does the opposite. The whole cookbook, .erb files included, is synced down to the node's cache, and chef-client evaluates the template there, during the converge phase (the part of a run where Chef stops reading your code and starts changing the machine), as whichever user the client runs as. On almost every server you will meet, that is root.

Three things follow. Nothing renders until the cookbook sync finishes, because the node needs the file in hand. The full node object is in scope during the render, so any resolved attribute is one expression away whether you passed it in or not. And the render is real Ruby with no sandbox around it. A template is executable code that happens to produce text, so give the templates/ directory the same suspicion you would give a shell script that runs on every host you own. That is what it is. Reviewed merges plus Policyfiles (Chef's way of pinning an exact cookbook version and content hash for a named group of nodes) are what stand between a bad .erb and the whole fleet.

terminal
$ tree cookbooks/web
output
cookbooks/web
├── attributes
│ └── default.rb
├── files
│ └── ca-bundle.crt
├── metadata.rb
├── recipes
│ └── default.rb
└── templates
├── default
│ └── app.env.erb
└── nginx.conf.erb
5 directories, 6 files

Chef looks for source 'nginx.conf.erb' under templates/, and it does not stop at the first copy it finds. It walks a fixed list, most specific first: templates/host-FQDN/ (FQDN meaning fully qualified domain name, the machine's full hostname, something like web01.example.com), then templates/PLATFORM-VERSION/, then templates/PLATFORM/, then templates/default/, then the top of templates/ itself. Partial versions count as well, and the more specific directory wins, so on Ubuntu 22.04 a templates/ubuntu-22.04/ copy beats a templates/ubuntu-22/ one. The files/ directory works the same way for cookbook_file. That ordering is a gift when you genuinely need a different config on Ubuntu 22.04 than on RHEL 9, and a trap when somebody drops a templates/ubuntu/ copy in for a one-off fix and forgets it. Every Ubuntu node in the fleet then quietly stops reading the file you have been editing.

The Resource, Property By Property

cookbooks/web/recipes/default.rb
service 'nginx' do
action :nothing # declared here so others can notify it
end
template '/etc/nginx/nginx.conf' do
source 'nginx.conf.erb' # found under cookbooks/web/templates/
owner 'root'
group 'root'
mode '0644' # a quoted string, always
variables(
worker_processes: node['web']['workers'],
upstreams: node['web']['upstreams']
)
verify 'nginx -t -c %{path}'
notifies :reload, 'service[nginx]', :delayed
end

variables is the explicit contract. Whatever you pass arrives inside the template as an instance variable, so worker_processes: becomes @worker_processes. Anything you did not pass is still reachable through node, because the whole node object is in scope. Prefer variables anyway. A template that names its inputs at the top of the resource can be read without hunting through five attribute files, and it is the difference between a template you can reuse and one welded to your attribute layout.

The rest earns its place too. verify runs a command against the rendered file before it goes live, with %{path} standing in for the temporary path. Old cookbooks spelled that token %{file}; it has been removed, and current Chef raises an ArgumentError telling you to use %{path}, so there is only one correct spelling now. notifies :reload, 'service[nginx]', :delayed fires only when the file actually changed, and :delayed collapses ten such notifications in a single run into one reload at the end. Three more properties you will want eventually: helper(:banner) { "..." } defines a method the template can call, which keeps Ruby logic out of the markup (helpers do ... end takes a whole module); cookbook 'other_cookbook' pulls the source from another cookbook's templates/ directory; and local true treats source as an absolute path already sitting on the node.

cookbooks/web/templates/nginx.conf.erb
<%# this comment never reaches the rendered file %>
# Managed by Chef. Local edits are erased on the next run.
worker_processes <%= @worker_processes %>;
events { worker_connections 1024; }
http {
upstream app {
<% @upstreams.each do |backend| %>
server <%= backend %> max_fails=3;
<% end %>
}
server {
listen 80;
server_name <%= node['fqdn'] %>; # straight off the node, nothing passed in
<% if node['web']['tls_enabled'] %>
listen 443 ssl;
ssl_certificate /etc/ssl/certs/<%= node['fqdn'] %>.pem;
<% end %>
location / { proxy_pass http://app; }
}
}

Three tags do all the work. <%= expr %> evaluates a Ruby expression and prints the result. <% code %> evaluates and prints nothing, which is how loops and conditionals happen. <%# note %> is a comment that never reaches the file. The loop turns a two-element array into two server lines and a nine-element array into nine, and the if leaves the TLS (Transport Layer Security, the encryption behind https) block out entirely on a host with no certificate.

Now a detail most Chef tutorials get slightly wrong. You will see -%> recommended everywhere to swallow the newline after a control tag. Chef does not render with Ruby's stock ERB. It renders with Erubis, a separate ERB engine Chef has shipped for years, and Erubis trims by default: a line containing nothing but <% ... %> and whitespace is removed whole, leading indentation and trailing newline included. The loop above produces clean output with no -%> anywhere, and an indented <% if %> will not shift the next line to the right. That is why Chef templates hurt far less than Jinja2 ones for formats like YAML (a config format where the indentation itself carries meaning). Writing -%> still works, so old cookbooks are not broken. Go hunting for the blank lines it was supposed to fix and you will not find them.

Watch It Render, Then Watch It Do Nothing

terminal
# on a bootstrapped node; under CINC, the open-source rebuild of Chef,
# the same binary is called cinc-client
$ sudo chef-client
output
Chef Infra Client, version 18.4.12
Patents: https://www.chef.io/patents
Infra Phase starting
Using Policyfile 'web-server' at revision '3f9a1c8b2d7e4a06c15b9f83e2d40a77b6c1e5f9'
Synchronizing cookbooks:
- web (0.4.2)
Installing cookbook gem dependencies:
Compiling cookbooks...
Loading Chef InSpec profile files:
Loading Chef InSpec input files:
Loading Chef InSpec waiver files:
Converging 2 resources
Recipe: web::default
* service[nginx] action nothing (skipped due to action :nothing)
* template[/etc/nginx/nginx.conf] action create
- update content in file /etc/nginx/nginx.conf from 4b2f8c to 9e1a37
--- /etc/nginx/nginx.conf 2026-07-21 09:14:19.102481022 +0000
+++ /etc/nginx/.chef-nginx20260721-4471-1jf2ku.conf 2026-07-21 09:14:22.114482301 +0000
@@ -1,11 +1,12 @@
# Managed by Chef. Local edits are erased on the next run.
-worker_processes 2;
+worker_processes 4;
events { worker_connections 1024; }
http {
upstream app {
server 10.0.1.11:8080 max_fails=3;
+ server 10.0.1.12:8080 max_fails=3;
}
server {
* service[nginx] action reload
- reload service service[nginx]
Running handlers:
Running handlers complete
Infra Phase complete, 2/2 resources updated in 04 seconds

Read the middle of that closely. from 4b2f8c to 9e1a37 are the first six characters of the checksum (a short fingerprint calculated from a file's contents) of the old file and the new one, which is how Chef decided anything needed doing at all. The +++ path is a hidden temp file staged in /etc/nginx/, next to the destination rather than in /tmp, and the .chef- prefix is Chef's own naming, deliberately stable so you can recognise its scratch files. The placement is deliberate too: the last step is a rename inside one filesystem, and that is atomic (it either happens completely or not at all), so no process ever reads half a config. Then the mode, owner and group you declared are applied, and the delayed reload runs at the end because the resource reported itself updated.

What a template resource actually does, in order
1Compile phase
recipe read, resource queued, nothing rendered yet
2Render on the node
Erubis evaluates the .erb, as root
3Stage beside the target
hidden .chef-* temp file in the destination directory
4Compare checksums
identical means no write, no verify, no notify
5Run verify
only when content changed; non-zero exit aborts the run
6Rename into place
atomic, then mode, owner and group applied
7Fire notifications
a :delayed reload runs once, at the end
The checksum comparison gates verify, so a run that changes nothing also validates nothing.
terminal
$ sudo chef-client # again, with nothing edited in between
output
Chef Infra Client, version 18.4.12
Patents: https://www.chef.io/patents
Infra Phase starting
Using Policyfile 'web-server' at revision '3f9a1c8b2d7e4a06c15b9f83e2d40a77b6c1e5f9'
Synchronizing cookbooks:
- web (0.4.2)
Installing cookbook gem dependencies:
Compiling cookbooks...
Loading Chef InSpec profile files:
Loading Chef InSpec input files:
Loading Chef InSpec waiver files:
Converging 2 resources
Recipe: web::default
* service[nginx] action nothing (skipped due to action :nothing)
* template[/etc/nginx/nginx.conf] action create (up to date)
Running handlers:
Running handlers complete
Infra Phase complete, 0/2 resources updated in 03 seconds

(up to date) and 0/2 resources updated is the entire point. Chef rendered the template again, hashed the result, found it identical to the file already on disk, wrote nothing and notified nothing. nginx was never reloaded. That is idempotence (running it twice changes nothing the second time), and it is what makes a chef-client firing every thirty minutes safe rather than terrifying. Break it and you get a self-inflicted outage. A template that embeds Time.now, or that loops over the results of a search() call whose order the server never promised to keep stable, renders new content every run and reloads production on a timer. Sort anything you loop over. And when a node reports a change twice in a row with nobody touching it, suspect the template first.

mode 644 is not the mode you think
Chef accepts both Strings and Integers for mode and treats them very differently. The conversion is (mode.respond_to?(:oct) ? mode.oct : mode.to_i) & 07777. A String answers to .oct, so '644' and '0644' both mean octal 644 (octal is base 8, the numbering Unix permissions have always used). An Integer does not answer to .oct, so Chef falls through to .to_i and masks with 07777, which means the bare number 644 is read as plain decimal and lands as mode 1204: sticky bit set, owner write-only, group nothing, others read. Nothing raises, because the range check only asks whether the value sits between 0 and 07777, and 644 comfortably does. The file exists, the run reports success, and the service that cannot read it fails in a way that looks nothing like a permissions bug. mode 0644 with the leading zero is a Ruby octal literal and is correct, but the quoted string is what a reviewer reads at a glance. Leaving mode off is its own trap: Chef creates a brand-new file the way the shell would, 0666 minus the run's umask (the setting that strips permission bits off every newly created file). Root's umask is normally 022, so you land on 0644. World-readable, on a file that may hold a password.

Verify Before The File Lands

verify is the taste test before the plate leaves the kitchen. Chef renders to the temp file, runs your command against that path, and only continues if the command exits zero. Here is what happens when somebody sets the worker count to a string nginx cannot parse.

terminal
# -j overrides node attributes for this run: {"web": {"workers": "4 8"}}
$ sudo chef-client -j /tmp/bad-workers.json
output
Converging 2 resources
Recipe: web::default
* service[nginx] action nothing (skipped due to action :nothing)
* template[/etc/nginx/nginx.conf] action create
================================================================================
Error executing action `create` on resource 'template[/etc/nginx/nginx.conf]'
================================================================================
Chef::Exceptions::ValidationFailed
----------------------------------
Proposed content for /etc/nginx/nginx.conf failed verification nginx -t -c %{path}
nginx: [emerg] invalid number of arguments in "worker_processes" directive in /etc/nginx/.chef-nginx20260721-4623-9kd2mv.conf:2
nginx: configuration file /etc/nginx/.chef-nginx20260721-4623-9kd2mv.conf test failed
Temporary file moved to /var/chef/cache/failed_validations/.chef-nginx20260721-4623-9kd2mv.conf
Resource Declaration:
---------------------
# In /var/chef/cache/cookbooks/web/recipes/default.rb
5: template '/etc/nginx/nginx.conf' do
6: source 'nginx.conf.erb' # found under cookbooks/web/templates/
7: owner 'root'
...
Running handlers:
[2026-07-21T09:31:07+00:00] ERROR: Running exception handlers
Running handlers complete
[2026-07-21T09:31:07+00:00] ERROR: Exception handlers complete
Infra Phase failed. 0 resources updated in 05 seconds
[2026-07-21T09:31:07+00:00] FATAL: Stacktrace dumped to /var/chef/cache/chef-stacktrace.out
[2026-07-21T09:31:07+00:00] FATAL: Chef::Exceptions::ValidationFailed: template[/etc/nginx/nginx.conf] (web::default line 5) had an error: Chef::Exceptions::ValidationFailed: Proposed content for /etc/nginx/nginx.conf failed verification nginx -t -c %{path}

Nothing happened, and that is the win. The live /etc/nginx/nginx.conf still holds the old, working content, because verify runs against the temp file before the rename. nginx was never reloaded, because the resource never reported itself updated and the delayed notification never fired. Two gifts are buried in that error. Chef pastes the verify command's own output straight into the exception, so nginx tells you the directive and the line number without you asking. And the rejected render is not thrown away: Chef copies it into /var/chef/cache/failed_validations/ under the same hidden .chef- name, so you can read the exact text that failed instead of reasoning about what you think it generated.

terminal
$ sudo sed -n '2p' /etc/nginx/nginx.conf
$ sudo sed -n '2p' /var/chef/cache/failed_validations/.chef-nginx20260721-4623-9kd2mv.conf
output
worker_processes 4;
worker_processes 4 8;

Two honest limits, so you do not over-trust it. verify only runs when the rendered content differs from what is on disk, so a config that is already broken on a node sails through any run that changes nothing. And nginx -t -c %{path} treats the temp file as a complete config, which is right for nginx.conf and wrong for a fragment dropped into conf.d/, where a bare server block on its own fails with "server" directive is not allowed here. Test the assembled config in a later resource instead. The one file where you should never skip this is /etc/sudoers, with verify '/usr/sbin/visudo -cf %{path}'. A malformed sudoers file means nobody on that host can become root again, including the Chef run you would have used to fix it.

Secrets, Permissions And What Lands In The Run Log

The rendered file is often the most sensitive object on the box: a database password, an API token, a private key. Two properties help, and one habit matters more than either. Never park a secret in a node attribute. Chef saves normal and automatic attributes back to the node object on the server at the end of every run, so a password written there is a password sitting in the Chef Infra Server database and in every knife node show from then on. Pass it through node.run_state instead, which lives in memory for the length of the run and is never persisted.

cookbooks/web/recipes/default.rb
template '/etc/app/app.env' do
source 'app.env.erb'
owner 'app'
group 'app'
mode '0640' # app can read it, nobody else can
sensitive true # keep the rendered diff out of the run log
variables(db_password: node.run_state['db_password'])
notifies :restart, 'service[app]', :delayed
end
terminal
# --force-formatter keeps the run-log format when standard output is a pipe
$ sudo chef-client --force-formatter 2>&1 | grep -A5 'app.env'
$ stat -c '%a %U %G %n' /etc/app/app.env
output
* template[/etc/app/app.env] action create
- update content in file /etc/app/app.env from 1f83b2 to c7e4a9
- suppressed sensitive resource
- change mode from '0644' to '0640'
- change owner from 'root' to 'app'
- change group from 'root' to 'app'
640 app app /etc/app/app.env

suppressed sensitive resource is the line doing the work. Without it, that diff goes to standard output, into your log shipper, into the node's saved run report and, where the data collector is enabled, up to Chef Automate. A password in a diff is a password in four systems that were never meant to hold it. Be clear about what sensitive does not do, though. It does not encrypt the file, it does not change the mode, and when verify fails on a sensitive resource Chef prints the literal word [sensitive] where the command and everything it printed would have gone. You lose your best debugging clue at exactly the moment you want it, so keep the rejected render in failed_validations in mind as your fallback.

A missing @ blows up at converge, on a branch you never tested
Values from the variables hash arrive as instance variables inside the template. Write worker_processes instead of @worker_processes and ERB reads it as a method call on the render context, raising undefined local variable or method. Chef wraps that in a Chef::Mixin::Template::TemplateError and prints the offending line under a Template Context: heading with on line #3, which is genuinely helpful. The timing is the problem. The recipe compiled fine, so the failure lands mid-converge, after other resources have already changed the machine. cookstyle will not catch it, because cookstyle lints Ruby recipes and never opens an .erb file. chef-client --why-run does render the template, since the content has to exist before Chef can show you a diff, so it catches a broken tag. But why-run skips every action, so anything an earlier resource should have created is missing. And neither one exercises the <% if %> branch that only fires on hosts with TLS enabled, which is where this bug likes to hide.

cookbook_file, For The Files That Must Not Change

Not every file has blanks in it. A CA bundle (certificate authority bundle, the list of signing authorities a machine is willing to trust), a logrotate config, a helper script: you want those on the node byte for byte, exactly as they sit in the repo. cookbook_file is the photocopier to template's form letter. It reads from the cookbook's files/ directory instead of templates/ and evaluates nothing, so a literal <%= ... %> in the source is written straight through. It shares the same owner, group, mode, verify, sensitive and notifies behaviour, so promoting a static file to a template later is a two-line change.

cookbooks/web/recipes/default.rb
execute 'update-ca-certificates' do
command '/usr/sbin/update-ca-certificates'
action :nothing # only runs when the bundle below changes
end
cookbook_file '/usr/local/share/ca-certificates/internal-ca.crt' do
source 'ca-bundle.crt' # from cookbooks/web/files/
owner 'root'
group 'root'
mode '0644'
notifies :run, 'execute[update-ca-certificates]', :delayed
end

Reach for it whenever the content has no business being evaluated. It removes a whole class of accident: nobody can smuggle Ruby into a file that is never handed to a template engine, and a stray <% inside a third-party config will not detonate your run at converge time on a Sunday.

Node Attributes Are Data The Node Itself Can Write

One habit is worth building early. node['app']['extra_directives'] looks like a value from your repo. It might not be. Because Chef writes normal and automatic attributes back to the server at the end of a run, using the node's own client key, a machine whose root account has been taken can persist an attribute of its own choosing and hand it to the next run of any cookbook that reads it. Loop that into a template for /etc/sudoers.d/app or a cron file and you have converted one compromised host into attribute-shaped remote code execution across everything sharing the cookbook.

The defence is boring and it works. Pass what the template needs through variables, sourced from something you control: a default attribute in the cookbook, a data bag (Chef's shared JSON store, which can be encrypted), or an attribute set in the Policyfile. Validate anything that arrived from outside before it reaches a privileged file, with a regular expression or an Integer() call that raises on junk. Keep verify on any file where a syntax error is a lockout. And when the value really is free-form text from elsewhere, ask hard why it needs to appear in a root-owned config at all.

Prove It Instead Of Assuming It

Templates fail late, so the way you check them has to run them. Test Kitchen builds a throwaway VM or container, converges the cookbook against it for real, and then runs InSpec (Chef's language for asserting what is actually true on a machine) against the result. Assert on the content, not on whether the run went green.

test/integration/default/nginx_test.rb
describe file('/etc/nginx/nginx.conf') do
it { should be_owned_by 'root' }
its('mode') { should cmp '0644' }
its('content') { should match(/^worker_processes 4;$/) }
its('content') { should include 'server 10.0.1.12:8080' }
end
describe file('/etc/app/app.env') do
its('mode') { should cmp '0640' } # never world-readable
end
describe command('nginx -t') do
its('exit_status') { should eq 0 }
end
terminal
$ kitchen verify default-ubuntu-2204
output
-----> Starting Test Kitchen (v3.6.0)
-----> Verifying <default-ubuntu-2204>...
Loaded tests from {:path=>"/home/dev/cookbooks/web/test/integration/default"}
Profile: tests from {:path=>"/home/dev/cookbooks/web/test/integration/default"}
Version: (not specified)
Target: ssh://[email protected]:2222
File /etc/nginx/nginx.conf
✔ is expected to be owned by "root"
✔ mode is expected to cmp == "0644"
✔ content is expected to match /^worker_processes 4;$/
✔ content is expected to include "server 10.0.1.12:8080"
File /etc/app/app.env
✔ mode is expected to cmp == "0640"
Command: `nginx -t`
✔ exit_status is expected to eq 0
Test Summary: 6 successful, 0 failures, 0 skipped
Finished verifying <default-ubuntu-2204> (0m3.42s).
-----> Test Kitchen is finished. (0m5.18s)

The content assertion is the one people skip, and it is the one that earns its keep. Matching ^worker_processes 4;$ proves the node data actually reached the file, rather than a default quietly winning because an attribute name was misspelled two files away. Pair it with a mode assertion on every rendered file that holds a secret and shipping the world-readable version stops being possible.

Quick check
01Where and when does the ERB inside nginx.conf.erb actually get evaluated?
Incorrect — Chef ships the .erb file itself down to the node; nothing is rendered at upload time.
Correct — the cookbook syncs into the node's cache and chef-client renders it there, which is why a template is code running as root.
Incorrect — The server stores cookbooks and node data; it has no template engine and never renders anything.
Incorrect — Compile only builds the resource collection; the render happens later, in converge, which is why template bugs surface so late in a run.
02A recipe says mode 644 with no quotes. What permissions does the file end up with?
Incorrect — Chef only gets octal from a quoted string or from the Ruby literal 0644; a bare 644 is plain decimal.
Incorrect — The check asks whether the number sits between 0 and 07777, and 644 decimal is well under that, so it passes and produces a wrong mode instead of an error.
Correct — Chef calls .oct on Strings but .to_i on Integers, so 644 decimal becomes mode 1204, sticky bit set and owner write-only.
Incorrect — Chef has no such default; with no mode property at all a brand-new file follows the run's umask, usually landing on 0644.
03A converge ends with Chef::Exceptions::ValidationFailed, 'Proposed content for /etc/nginx/nginx.conf failed verification nginx -t -c %{path}', and 'Infra Phase failed. 0 resources updated'. What is true, and what do you do next?
Correct — verify runs against the staged temp file before the rename, and Chef copies the rejected content there precisely so you can read the text that failed.
Incorrect — Verification happens before the temp file is renamed into place, so the live file never changed and the delayed notification never fired.
Incorrect — The render succeeded, since verify only ever runs on already-rendered content, and cookstyle lints Ruby recipes without ever opening an .erb.
Incorrect — There is no rollback step anywhere in the file provider; verify runs first, which is exactly why nothing needed rolling back.

Try this

Run tree cookbooks/web 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: mode 644 is not the mode you think. 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