Resources & recipes
The declarative Ruby building blocks.
A shell script is turn-by-turn directions. Left at the lights, right after the church, third door along. It works right up until you are already past the church, and then every instruction after that one is wrong. A Chef resource is the postcode you hand the satnav instead. You say where you want to end up, and something else works out the route from wherever you actually are.
The first style is imperative: you list the steps. The second is declarative: you state the end result. A resource is one declaration of desired state. This package installed. This file present with this content and these permissions. This service switched on at boot and running now. A recipe is an ordered list of those declarations in a Ruby file, and a cookbook is the folder that holds recipes, templates and default settings together. You write the destination. The provider, the platform-aware code sitting behind each resource, does the driving.
For security and operations work, that inversion is the whole payoff. A hardening baseline is a set of claims about end state. SSH must not accept passwords. This key file must be mode 0600 owned by root. The audit daemon must be running. Claims can be re-asserted, cheaply, forever. chef-client, the agent installed on each machine, wakes up every 30 minutes by default, re-checks every claim, corrects whatever drifted, and prints exactly what it touched. Somebody edits a config by hand at 2am. The next converge, meaning one full check-and-correct pass, puts it back and names the file in the log.
Anatomy Of A Resource
Every resource has the same four parts. The type (package, file, service) picks the provider, the code that knows apt from dnf and systemd from init. The name is the resource's identity for the whole run, and it doubles as the default value of the most important property, the one Chef calls the name property: for package that is package_name, for file and template it is path. Properties describe the state you want. The action is the verb. Read one aloud as a sentence. This package, installed.
Leave the action out and each resource falls back to its own default. package installs. file, template and directory create. execute runs. service is the odd one out, and it catches people: its default action is :nothing, so a bare service 'nginx' line sits in the run doing absolutely nothing until something notifies it. If you want it enabled at boot and running now, say so with action [:enable, :start], which runs left to right.
## Cookbook:: webserver# Recipe:: default## Refresh the package list at most once a day, not on every convergeapt_update 'daily cache refresh' dofrequency 86_400action :periodicend# One resource, one apt transaction, both packagespackage %w(nginx curl)directory '/etc/nginx/conf.d' doowner 'root'group 'root'mode '0755'endtemplate '/etc/nginx/nginx.conf' dosource 'nginx.conf.erb'owner 'root'group 'root'mode '0644'variables(worker_connections: node['webserver']['worker_connections'])# Syntax-check the rendered file before it replaces the live oneverify 'nginx -t -c %{path}'# Ring the service only if this file actually changednotifies :reload, 'service[nginx]', :delayedendservice 'nginx' doaction [:enable, :start]end
Four lines in there are doing more work than they look like. package takes an array, so nginx and curl go to apt in a single transaction instead of two, which is faster and lets apt resolve both dependency sets together. apt_update with action :periodic refreshes the package cache only when the existing one is older than frequency seconds; an execute running apt-get update by hand would hammer your mirrors on every converge until the end of time. verify is the taste test before the dish leaves the kitchen: Chef renders the template into a temporary file in the destination directory, runs your command with %{path} swapped for that temp path, and moves it into place only if the command exits 0. And notifies points at service[nginx], which is not declared until ten lines further down. That works because the entire recipe is compiled into the resource collection before anything converges, so any resource can address any other by type[name], including one declared in a different recipe of the same run list.
Two smaller things before you run it. The run list is the ordered set of recipes a node is supposed to apply, and on a real fleet you pin the cookbook versions behind it with a Policyfile; roles and environments still work but they are the legacy path. And the default value for worker_connections lives in attributes/default.rb, which is the next lesson.
$ cd ~/chef-repo$ sudo chef-client --local-mode --runlist 'recipe[webserver::default]'
[2026-07-21T10:14:01+00:00] WARN: No config file found or specified on command line. Using command line options instead.Chef Infra Client, version 18.4.12Patents: https://www.chef.io/patentsInfra Phase startingResolving cookbooks for run list: ["webserver::default"]Synchronizing cookbooks:- webserver (0.1.0)Installing cookbook gem dependencies:Compiling cookbooks...Loading Chef InSpec profile files:Loading Chef InSpec input files:Loading Chef InSpec waiver files:Converging 5 resourcesRecipe: webserver::default* apt_update[daily cache refresh] action periodic- update new lists of packages* apt_package[nginx, curl] action install- install version 1.24.0-2ubuntu7.3 of package nginx* directory[/etc/nginx/conf.d] action create (up to date)* template[/etc/nginx/nginx.conf] action create- update content in file /etc/nginx/nginx.conf from 2c9e41 to 8f13a7--- /etc/nginx/nginx.conf 2026-07-21 10:14:07.482119000 +0000+++ /etc/nginx/.chef-nginx20260721-4821-1p2q3r.conf 2026-07-21 10:14:07.478119000 +0000@@ -4,7 +4,7 @@pid /run/nginx.pid;events {- worker_connections 768;+ worker_connections 1024;}http {* service[nginx] action enable (up to date)* service[nginx] action start (up to date)* service[nginx] action reload- reload service service[nginx]Running handlers:Running handlers completeInfra Phase complete, 4/7 resources updated in 14 seconds
Three things in that log repay a stare. You wrote package, and the log says apt_package[nginx, curl]. Chef picked the platform-specific resource while it was compiling the recipe, and the identical file would print dnf_package on Rocky Linux. Next, the reload landed at the very bottom, after every other resource had finished, because the notification timer was :delayed. Then the last line, which does not add up until somebody tells you the trick. Chef announced 5 resources and finished 4 of 7. The summary counts actions, not resources, and the service contributes three of them on its own: enable, start, and the reload it was notified into. Four actions changed something. Now run the identical command again and watch what goes missing.
$ sudo chef-client --local-mode --runlist 'recipe[webserver::default]'
[2026-07-21T10:16:33+00:00] WARN: No config file found or specified on command line. Using command line options instead.Chef Infra Client, version 18.4.12Patents: https://www.chef.io/patentsInfra Phase startingResolving cookbooks for run list: ["webserver::default"]Synchronizing cookbooks:- webserver (0.1.0)Installing cookbook gem dependencies:Compiling cookbooks...Loading Chef InSpec profile files:Loading Chef InSpec input files:Loading Chef InSpec waiver files:Converging 5 resourcesRecipe: webserver::default* apt_update[daily cache refresh] action periodic (up to date)* apt_package[nginx, curl] action install (up to date)* directory[/etc/nginx/conf.d] action create (up to date)* template[/etc/nginx/nginx.conf] action create (up to date)* service[nginx] action enable (up to date)* service[nginx] action start (up to date)Running handlers:Running handlers completeInfra Phase complete, 0/6 resources updated in 04 seconds
Zero. That is the number you are working towards, and it is what idempotent means: run it twice and the second run changes nothing. Six actions compared, none of them different. No reload section either, because nothing changed, so nothing rang the bell. On a fleet that count becomes a monitoring signal. A node that has reported zero updated every half hour for a month and suddenly reports two at 4am is telling you something moved underneath Chef, and the run log names the file and shows you the diff. If you are running CINC, the community rebuild of the Chef tools with the trademarks stripped out, every word of this holds and the binary is called cinc-client.
Compile Now, Converge Later
A chef-client run makes two passes, the way a kitchen reads every ticket on the rail before anybody touches a pan. In the compile phase, Chef evaluates the recipes in your run list as Ruby, top to bottom, and every resource block you write is appended to the resource collection, an ordered list and nothing cleverer. Nothing is installed. Nothing is written. In the converge phase, Chef walks that list in order and asks each provider two questions: what is the current state, and what did they ask for. Only the gap between those two answers turns into work.
The consequence trips nearly everyone once. Plain Ruby you write between resources runs during compile, which is before any resource has done a single thing.
# WRONG. This if is evaluated during compile, before the deploy# resources have created anything. On a fresh node the path does# not exist yet, so the file resource is never even declared, and# the run reports success while doing nothing.if ::File.exist?('/opt/app/current/BUILD_ID')file '/etc/app/build-id' docontent ::File.read('/opt/app/current/BUILD_ID')endend# RIGHT. The resource is always declared. The guard is evaluated# at converge time, in list order, and lazy defers reading the# file until the moment this resource actually runs.file '/etc/app/build-id' docontent lazy { ::File.read('/opt/app/current/BUILD_ID') }mode '0644'only_if { ::File.exist?('/opt/app/current/BUILD_ID') }end
Guards and lazy are the two levers that move work from compile time to converge time. A guard block runs when its resource runs, in list order. lazy defers a property's value to that same moment. If you need arbitrary Ruby during the converge rather than during the compile, wrap it in a ruby_block resource, which is a resource like any other and therefore sits in the collection exactly where you put it.
Guards Keep The Escape Hatch Honest
Sooner or later you hit something no resource models, and you reach for execute. execute knows nothing about your command. It cannot tell whether the work is already done, so it runs the thing on every converge, which on a scheduled node means every 30 minutes until somebody notices the load. A guard is the bouncer on the door: one condition, one decision about who gets in. not_if skips the resource when the condition is true. only_if runs it only when the condition is true. execute has a third option, creates, which skips the command when the named file already exists. If you also set cwd, creates is resolved relative to it, so keep the path absolute.
Guards come in two flavours. A string guard is a shell command, where exit status 0 means true and anything else means false. A block guard is Ruby, evaluated inside chef-client itself. Here is the part people get backwards. Both kinds run as whoever is running chef-client, which is normally root, even when the resource they guard carries user 'deploy'. So you can write a guard that cheerfully reads a root-only file and then watch the command itself fail as the unprivileged user, every single converge, with no error to explain it. If you want the guard to run under the same account, working directory and environment as the command, set guard_interpreter :execute (or :bash), which turns the guard into a real execute resource and copies user, group, cwd, environment and umask across from the parent.
## Cookbook:: webserver# Recipe:: tuning## creates is the cheapest guard there is: if the file is already on# disk, the command never runs. Generating 4096-bit dhparam (the# Diffie-Hellman parameters nginx uses for key exchange) takes# minutes, and you want to pay for it exactly once.# execute allows a command 3600 seconds by default; half an hour is# plenty here, and a bounded timeout stops a wedged openssl from# holding the whole run open.execute 'generate-dhparam' docommand 'openssl dhparam -out /etc/nginx/dhparam.pem 4096'creates '/etc/nginx/dhparam.pem'timeout 1800end# String guard: a shell command whose exit status is the answer.# sysctl --system reloads every kernel tuning file on the box, so# the guard checks one value as a cheap stand-in for "already done".execute 'load-hardening-sysctls' docommand 'sysctl --system'not_if 'sysctl -n kernel.kptr_restrict | grep -qx 2'end
$ sudo chef-client --local-mode --runlist 'recipe[webserver::tuning]'
[2026-07-21T10:22:09+00:00] WARN: No config file found or specified on command line. Using command line options instead.Chef Infra Client, version 18.4.12Patents: https://www.chef.io/patentsInfra Phase startingResolving cookbooks for run list: ["webserver::tuning"]Synchronizing cookbooks:- webserver (0.1.0)Installing cookbook gem dependencies:Compiling cookbooks...Loading Chef InSpec profile files:Loading Chef InSpec input files:Loading Chef InSpec waiver files:Converging 2 resourcesRecipe: webserver::tuning* execute[generate-dhparam] action run (up to date)* execute[load-hardening-sysctls] action run (skipped due to not_if)Running handlers:Running handlers completeInfra Phase complete, 0/1 resources updated in 02 seconds
The log tells the two apart, and the difference matters when you are reading someone else's run at midnight. creates made the provider decide there was nothing to do, so that resource reports up to date and gets counted. The not_if turned its resource away before the provider was ever asked, and a skipped resource drops out of the tally altogether. That is why two resources converge and the final line counts one. Keep string guards as fixed strings, by the way. They are run by the client as root, with a real shell involved the moment there is a pipe or a semicolon in them, so interpolating a node attribute or a data bag value into one hands whoever can write that value a root command at the next converge. On a Chef Infra Server, ordinary node attributes are writable by anybody with edit rights on the node object, and that is a wider group than you would like.
Notifications Fire On Change, Not On Run
Notifications are wiring, not scheduling. notifies :reload, 'service[nginx]', :delayed reads as: if I genuinely changed something, ring the service. The target is addressed as type[name] and looked up in the whole run's collection. subscribes is the same wire soldered from the other end, declared on the receiver, which is what you reach for when the source resource lives inside a community cookbook you would rather not fork.
Timing is the second half of it. :delayed is the default. Delayed actions are queued, de-duplicated by resource and action, and flushed once at the end of the converge, so three changed config files buy you one reload instead of three. :immediately runs the target's action right then, before the next resource in the list, which is what you want when a later resource depends on the effect: write a systemd unit file, notify a daemon-reload immediately, then start the service. :before is the rare one. It runs the target's action before the notifying resource converges, and only when that resource is really going to change, which is how you stop a service before swapping the binary underneath it.
Now break the config on purpose. Drop a stray brace into nginx.conf.erb, the template file Chef renders, and converge again.
$ sudo chef-client --local-mode --runlist 'recipe[webserver::default]'
[2026-07-21T10:31:44+00:00] WARN: No config file found or specified on command line. Using command line options instead.Chef Infra Client, version 18.4.12Patents: https://www.chef.io/patentsInfra Phase startingResolving cookbooks for run list: ["webserver::default"]Synchronizing cookbooks:- webserver (0.1.0)Installing cookbook gem dependencies:Compiling cookbooks...Loading Chef InSpec profile files:Loading Chef InSpec input files:Loading Chef InSpec waiver files:Converging 5 resourcesRecipe: webserver::default* apt_update[daily cache refresh] action periodic (up to date)* apt_package[nginx, curl] action install (up to date)* directory[/etc/nginx/conf.d] action create (up to date)* 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}"Resource Declaration:---------------------# In /home/ops/chef-repo/cookbooks/webserver/recipes/default.rb21: template '/etc/nginx/nginx.conf' do22: source 'nginx.conf.erb'23: owner 'root'24: group 'root'25: mode '0644'26: variables(worker_connections: node['webserver']['worker_connections'])27: # Syntax-check the rendered file before it replaces the live one28: verify 'nginx -t -c %{path}'29: # Ring the service only if this file actually changed30: notifies :reload, 'service[nginx]', :delayed31: endCompiled Resource:------------------# Declared in /home/ops/chef-repo/cookbooks/webserver/recipes/default.rb:21:in `from_file'template("/etc/nginx/nginx.conf") doaction [:create]default_guard_interpreter :defaultsource "nginx.conf.erb"declared_type :templatecookbook_name "webserver"recipe_name "default"owner "root"group "root"mode "0644"variables {:worker_connections=>1024}verify ["nginx -t -c %{path}"]endSystem Info:------------chef_version=18.4.12platform=ubuntuplatform_version=24.04ruby=ruby 3.1.4p223 (2023-03-30 revision 957bb7cb81) [x86_64-linux]program_name=/usr/bin/chef-clientexecutable=/opt/chef/bin/chef-clientRunning handlers:[2026-07-21T10:31:50+00:00] ERROR: Running exception handlersRunning handlers complete[2026-07-21T10:31:50+00:00] ERROR: Exception handlers completeInfra Phase failed. 0 resources updated in 06 seconds[2026-07-21T10:31:50+00:00] FATAL: Stacktrace dumped to /root/.chef/local-mode-cache/cache/chef-stacktrace.out[2026-07-21T10:31:50+00:00] FATAL: Please provide the contents of the stacktrace.out file if you file a bug report[2026-07-21T10:31:50+00:00] FATAL: Chef::Exceptions::ValidationFailed: template[/etc/nginx/nginx.conf] (webserver::default line 21) had an error: Chef::Exceptions::ValidationFailed: Proposed content for /etc/nginx/nginx.conf failed verification "nginx -t -c %{path}"
Read the order of events. The live file was never touched. The notification never fired. nginx carried on serving the config it already had, and the run exited non-zero, which your CI job or your monitoring can act on. Take verify away and the same stray brace writes a broken file, notifies a reload, and nginx refuses to come back on every node in the batch at roughly the same second. One line of Ruby is the whole distance between a failed run and an outage.
What The Converge Log Gives Away
Chef prints a unified diff, the same plus-and-minus format git uses, for every file it changes. That is a gift while you are auditing a change and a liability when the file is a private key. The run output goes to your terminal, to the journal or /var/log/chef/client.log on a scheduled node depending on how the service is configured, and, if the data collector is switched on, into Chef Automate as a report that a lot of people can read.
## Cookbook:: webserver# Recipe:: tls#directory '/etc/nginx/ssl' doowner 'root'group 'root'mode '0750'end# A data bag is Chef's key/value store on the server; use an# encrypted one in real life. See the data bags lesson.tls = data_bag_item('certs', 'app-tls')file '/etc/nginx/ssl/app.key' docontent tls['private_key']owner 'root'group 'root'mode '0600'end
$ sudo chef-client --local-mode --runlist 'recipe[webserver::tls]'
[2026-07-21T10:41:08+00:00] WARN: No config file found or specified on command line. Using command line options instead.Chef Infra Client, version 18.4.12Patents: https://www.chef.io/patentsInfra Phase startingResolving cookbooks for run list: ["webserver::tls"]Synchronizing cookbooks:- webserver (0.1.0)Installing cookbook gem dependencies:Compiling cookbooks...Loading Chef InSpec profile files:Loading Chef InSpec input files:Loading Chef InSpec waiver files:Converging 2 resourcesRecipe: webserver::tls* directory[/etc/nginx/ssl] action create- create new directory /etc/nginx/ssl- change mode from '' to '0750'- change owner from '' to 'root'- change group from '' to 'root'* file[/etc/nginx/ssl/app.key] action create- create new file /etc/nginx/ssl/app.key- update content in file /etc/nginx/ssl/app.key from none to 6b8f2c--- /etc/nginx/ssl/app.key 2026-07-21 10:41:12.118441000 +0000+++ /etc/nginx/ssl/.chef-app20260721-5120-9dk3lz.key 2026-07-21 10:41:12.114441000 +0000@@ -0,0 +1,4 @@+-----BEGIN PRIVATE KEY-----+MIIEvQIBADANBgkqhkiG9w0BAQEFAASCBKcwggSjAgEAAoIBAQDHm3rB1kQ9wZ8t+7Qv1oJ0aXlKq2mJ8yYb4pR6dNwF3sTgH0cKz9VuEr5nBqLdX2fMhA1jP8kCyRtWo+-----END PRIVATE KEY------ change mode from '' to '0600'Running handlers:Running handlers completeInfra Phase complete, 2/2 resources updated in 03 seconds
There is the key. In the terminal, in the node's log file, in the run report, and in whatever ships those logs to your SIEM (security information and event management system, the central log store your detection team queries all day). The fix is one property. sensitive true keeps the fact of the change and drops the content. On file and template it replaces the diff. On execute it keeps the command's output out of the log and out of the failure message as well.
file '/etc/nginx/ssl/app.key' docontent tls['private_key']owner 'root'group 'root'mode '0600'sensitive trueend
# the data bag item was rotated, so the content really does change$ sudo chef-client --local-mode --runlist 'recipe[webserver::tls]'
[2026-07-21T10:47:55+00:00] WARN: No config file found or specified on command line. Using command line options instead.Chef Infra Client, version 18.4.12Patents: https://www.chef.io/patentsInfra Phase startingResolving cookbooks for run list: ["webserver::tls"]Synchronizing cookbooks:- webserver (0.1.0)Installing cookbook gem dependencies:Compiling cookbooks...Loading Chef InSpec profile files:Loading Chef InSpec input files:Loading Chef InSpec waiver files:Converging 2 resourcesRecipe: webserver::tls* directory[/etc/nginx/ssl] action create (up to date)* file[/etc/nginx/ssl/app.key] action create- update content in file /etc/nginx/ssl/app.key from 6b8f2c to c41d9e (suppressed sensitive resource)Running handlers:Running handlers completeInfra Phase complete, 1/2 resources updated in 03 seconds
Two things sensitive does not do. It does not hide that the resource changed, and you want that visible. It does not protect where the secret came from, either, so keep secrets out of ordinary node attributes, which chef-client writes back to the Chef Infra Server after every run and which anyone who can read the node object can read. Encrypted data bags and Chef Vault are the right home for them, and they get a lesson of their own.
While you are being careful with that file, be pedantic about its mode. Quote it. mode '0600' is a string, and Chef hands it to chmod exactly as written. Leave the quotes off a number like 644 and Ruby passes chmod the decimal value 644, which as a set of permission bits is octal 1204: sticky bit on, owner write-only, world readable. Backwards from what you typed, in the dangerous direction. The confusing part is that mode 0644 with the leading zero does work, because that is an octal literal in Ruby, which is exactly why quoting every mode you write is the habit worth having. cookstyle, the linter that ships with Chef Workstation, catches the whole family. Here it is on a copy of default.rb where the quotes were dropped.
$ cd ~/chef-repo/cookbooks/webserver$ cookstyle recipes/
Inspecting 4 files.C..Offenses:recipes/default.rb:25:10: C: [Correctable] Chef/Style/FileMode: Use strings for file modes.mode 644^^^4 files inspected, 1 offense detected, 1 offense autocorrectable
cookstyle -a rewrites that in place. Then converge the cookbook twice against a throwaway virtual machine or container before you push, which is what kitchen converge does for you, and let kitchen verify run your InSpec checks (InSpec is Chef's language for writing tests that describe what a finished machine should look like). The first run proves the recipe works. The second run, the boring one that reports 0 updated, is the one that proves you wrote Chef and not a shell script in a Ruby costume.
Try this
Run sudo chef-client --local-mode --runlist 'recipe[webserver::default]' 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: two Ways A Guard Quietly Lies. Check that on your own systems before you need to, because it is cheaper to find on a quiet afternoon than during an incident.