Policyfiles & environments
Pin exactly what a node runs.
A cookbook version constraint like ~> 11.1 is a standing order at a shop, not a receipt. It tells the dependency resolver what you are willing to accept, and the resolver goes shopping fresh every time somebody runs it. Ask on Tuesday and you get nginx 11.1.2. Ask on Friday, after the maintainer ships 11.4.0, and you get that instead. Your repository did not change. Code review did not change. The code that runs as root on your web tier changed.
Policyfiles turn the standing order into a receipt with lot numbers on it. You still write the constraint. The shopping happens exactly once, on a workstation, and the answer gets written down: every cookbook in the graph, its exact version, and a fingerprint of the actual file contents. That file is committed next to the code. From then on, "what is running in production" has a written answer instead of a guess about what Supermarket (Chef's public cookbook registry, the shop in this story) had in stock the last time anyone uploaded.
What the Old Workflow Leaves Open
The pre-Policyfile setup has three moving parts and all three float. Berkshelf, the older tool for fetching dependencies, resolves your graph and uploads cookbooks to the Chef Infra Server. Environments can carry cookbook version constraints. Roles carry run-lists, the ordered list of recipes a machine runs. None of the three is versioned as a single unit, and the server is happy to let you write over what is already there. knife cookbook upload will replace nginx 11.1.2 with completely different content under the same version number unless somebody remembered the --freeze flag. A node pinned to "11.1.2" is pinned to a label. Labels peel off and restick.
Do the attacker math on that. Somebody with upload rights to the Chef Infra Server (a leaked workstation key, a compromised CI runner, where CI is continuous integration, a contractor whose access nobody revoked) never has to touch your Git repository. They re-upload one cookbook version with an extra execute resource buried in a recipe. Every node picks it up on its next converge, and a converge is one client run where the machine is made to match the recipes, running as root. Your git log shows a quiet week. Roles have the same shape of hole. Edit role[web] on the server and every node reading that role behaves differently at the next run, with no review, no diff, and no earlier version to roll back to.
The Policyfile Is the Shopping List
A Policyfile is one Ruby file at the root of your cookbook repository. It names a policy, lists the recipes a node should run, and says where cookbooks come from. It takes over the job of the run-list you used to set on the node and the role you used to point it at. Notice what cannot appear in it. A Policyfile run-list holds recipes only, never roles, because a role is an unversioned pointer stored on the server and that pointer is precisely what this file exists to delete.
# Policyfile.rb - the source of truth for what a "web" node becomes.name 'web'# Where to look for cookbooks not pinned to a path or a git remote.default_source :supermarket, 'https://supermarket.chef.io'# Recipes only. `role[web]` is not valid here and never will be.run_list 'base::default', 'nginx::default', 'web::default'# A second, named run-list you invoke on demand:# chef-client --named-run-list patchnamed_run_list :patch, 'base::patch'# The cookbook under development, taken straight out of this repo.cookbook 'web', path: '.'# Pinned to a git tag, so the source is auditable as well as the artifact.cookbook 'base', git: 'https://github.com/acme/base-cookbook.git', tag: 'v1.4.2'# From Supermarket, with a pessimistic constraint:# "at least 11.1, but below 12.0" - and only until the lock is written.cookbook 'nginx', '~> 11.1'# Attributes that used to live on a role or an environment.# Policyfiles support default and override levels only.default['nginx']['worker_processes'] = 4override['web']['tls_min_version'] = '1.2'
The ~> is the pessimistic operator, Ruby's way of saying "newer, but not so much newer that it breaks." ~> 11.1 accepts anything from 11.1 up to but not including 12.0. That constraint matters for about four seconds, which is the interesting part. chef install reads the whole graph, including the dependencies your dependencies declare, picks one set of versions that satisfies every constraint at once, and writes the answer into Policyfile.lock.json.
# Resolve the graph once and write it down.# This talks to Supermarket and GitHub. It does NOT touch your Chef Infra Server.chef install
Building policy webExpanded run list: recipe[base::default], recipe[nginx::default], recipe[web::default]Caching Cookbooks...Installing base >= 0.0.0 from gitUsing web >= 0.0.0 from pathInstalling nginx 11.1.2Lockfile written to /home/dev/cookbooks/web/Policyfile.lock.jsonPolicy revision id: 8e7d6c5b4a3928170f6e5d4c3b2a1908f7e6d5c4b3a291807f6e5d4c3b2a1908
Two things about that run surprise people. First, no Chef Infra Server was involved. chef install fetches cookbooks into a workstation cache and produces a file, nothing more. Cookbooks pinned to a path or a git remote are fetched at whatever they point to, which is why they print >= 0.0.0 rather than a solved version. Second, now that Policyfile.lock.json exists, chef install stops resolving. Widen the constraint in Policyfile.rb, run chef install again, and you get the versions you already had, because the lock is the instruction now and the Policyfile is documentation. chef update is the command that re-reads Policyfile.rb and rewrites the lock. Knowing which of the two you typed is most of the skill.
Reading the Lock
The lock file is a packing list with a tamper seal on every item. It is JSON (JavaScript Object Notation, the plain text data format Chef uses for almost everything on the wire), it is meant to be committed, and it is meant to be read in a pull request. Pull out the fields that carry the weight using jq, a small command line tool for slicing JSON.
# What run-list did this revision freeze, and what is the revision called?jq '{revision_id, run_list}' Policyfile.lock.json# What exactly got pinned, and from where?jq '.cookbook_locks | map_values({version, identifier, source_options})' Policyfile.lock.json
{"revision_id": "8e7d6c5b4a3928170f6e5d4c3b2a1908f7e6d5c4b3a291807f6e5d4c3b2a1908","run_list": ["recipe[base::default]","recipe[nginx::default]","recipe[web::default]"]}{"base": {"version": "1.4.2","identifier": "5f0e1a9c7d3b46528e0a1c7d9b3e5f2a4c6d8e01","source_options": {"git": "https://github.com/acme/base-cookbook.git","revision": "9c1f4a7e2b8d5063a1f4c7e2b8d5063a1f4c7e2b"}},"nginx": {"version": "11.1.2","identifier": "a3f0d7c9e5b1428f6c0d9a2e7b4c81df3e6a0592","source_options": {"artifactserver": "https://supermarket.chef.io/api/v1/cookbooks/nginx/versions/11.1.2/download","version": "11.1.2"}},"web": {"version": "0.3.1","identifier": "c81df3e6a05926b2f0d7c9e5b1428f6c0d9a2e7b","source_options": {"path": "."}}}
Look at what happened to the git pin. You wrote tag: 'v1.4.2', and the lock recorded a commit hash instead, because a tag can be moved and a commit cannot. version is a string a human typed into metadata.rb. identifier is computed from the cookbook's actual files, and it is the field that changes the security story. When you push, each cookbook lands on the Chef Infra Server as a cookbook artifact addressed by that identifier rather than by its version number. Two cookbooks that both call themselves nginx 11.1.2 but differ by one line are two different artifacts, stored side by side without colliding. A node running a locked revision is bound to identifiers, so the overwrite trick from earlier has nothing to grab. Re-uploading "the same version" with new content produces a new artifact that nobody is pointing at.
revision_id is the fingerprint of the entire lock: the run-list, every cookbook identifier, every attribute you set in the Policyfile. Change worker_processes from 4 to 8 and the revision id changes, because the thing you are shipping changed. That single string is the unit you promote, and it turns "is staging running the same code as production" into a comparison of two strings rather than two people's memories.
Policy Groups Are Where Environments Went
A policy group is a shelf label, not a box. It holds no content of its own. It says "the shelf called prod currently holds revision 8e7d6c5b4a of the policy called web", and that is the whole idea. A node subscribes with two settings: policy_name picks the policy, policy_group picks the shelf to read it off. Move the label, and every node on that shelf converges on something different at its next run. Nothing else about the node changes.
# Upload the artifacts and point the dev group at this revision.chef push dev
Uploading policy web (8e7d6c5b4a) to policy group devUploaded base 1.4.2 (5f0e1a9c)Uploaded nginx 11.1.2 (a3f0d7c9)Uploaded web 0.3.1 (c81df3e6)
This is the first command in the whole workflow that speaks to the Chef Infra Server. It uploads any cookbook artifact the server does not already hold, stores the policy revision, and points the named group at it. Those short strings in brackets are the first eight characters of each content identifier, which is your receipt that the bytes on the server match the bytes in the lock. A group holds at most one revision of a given policy, so pushing again replaces the association. The revision that was there before does not vanish. It stays on the server as an artifact nobody references, which matters later.
chef push prd instead of chef push prod and Chef creates a brand new policy group called prd, uploads everything to it, and prints a cheerful success line. No node is subscribed to prd, so nothing happens, prod stays on the previous revision, and you walk away believing you shipped. The same missing guard rail means chef push prod from a laptop, on an uncommitted branch, at 2am, works exactly as well as it does from your release pipeline. Put the human gate in your build pipeline and restrict who holds a client key with write access to the policy_groups and cookbook_artifacts containers on the server, because the command line tool will not stop anybody.# On the node. This is the entire subscription.chef_server_url 'https://chef.acme.internal/organizations/acme'node_name 'web-01.acme.internal'client_key '/etc/chef/client.pem'policy_name 'web' # which policy to runpolicy_group 'prod' # which shelf to read it off# Note what is NOT here: no run-list, no cookbook versions, no revision id.# The node asks the server "what is on the prod shelf?" on every single run.
# New nodes can be subscribed at bootstrap time instead of by editing client.rb.knife bootstrap 10.20.4.11 -N web-02.acme.internal -U ubuntu --sudo \--policy-name web --policy-group prod# Which revision is each group pointing at right now?chef show-policy
web===* dev: 8e7d6c5b4a* staging: 8e7d6c5b4a* prod: a91f4d8c2e
Read that as a state of the world. The candidate release is sitting on dev and staging. Production is still on a91f4d8c2e, last week's revision, and will stay there until somebody moves the label. Nobody has to freeze anything or book a change window to keep it that way, which is the quiet benefit. Production moves forward only when a human pushes.
Promotion Is a Push, Not a Re-Resolve
Here is the discipline the whole design rests on. To promote a release, you check out the commit that passed staging and push that same lock file to the next group. You do not run chef update on the way. The moment you re-resolve before production, you have shipped a set of cookbooks nobody tested, and every hour of staging soak time you paid for is worth nothing. Test Kitchen keeps this honest earlier on. It builds a throwaway virtual machine or container, converges it, and destroys it. Point its chef_infra provisioner (renamed from chef_zero in Test Kitchen 3.0, and the old name still works) at your Policyfile.rb, and the throwaway machine converges the identical locked set the group is about to receive. InSpec, Chef's testing language, then checks the result from the outside: is the port listening, is the file mode what you claimed.
# The commit staging validated. No chef update anywhere in here.git checkout -q v2026.07.14chef install # repopulates the local cache FROM the lock; resolves nothingchef push prodchef show-policy
Installing cookbooks from lockUploading policy web (8e7d6c5b4a) to policy group prodUsing base 1.4.2 (5f0e1a9c)Using nginx 11.1.2 (a3f0d7c9)Using web 0.3.1 (c81df3e6)web===* dev: 8e7d6c5b4a* staging: 8e7d6c5b4a* prod: 8e7d6c5b4a
Every cookbook says Using rather than Uploaded, because the server already holds those exact identifiers from the dev push. Rolling back has the same shape. Check out the previous release tag and push it. The old cookbook artifacts are still there, so the push is quick and the nodes converge back onto a revision that already worked. Going forward, chef update is the one command that changes what you run, and its targeted form is worth memorising properly. The first positional argument is always the Policyfile path, so chef update nginx goes looking for a file called nginx and fails. chef update Policyfile.rb nginx re-resolves that one cookbook and holds the rest of the graph still, which is what you want when a CVE (Common Vulnerabilities and Exposures, the public catalogue of known security bugs) lands in one dependency and you have no appetite for dragging fourteen unrelated upgrades into the same change. chef update --attributes rewrites only the attribute section of the lock.
# Patch one cookbook, deliberately, with the rest of the graph pinned.chef update Policyfile.rb nginxgit diff --stat Policyfile.lock.jsonjq -r '.cookbook_locks | to_entries[] | "\(.key) \(.value.version) \(.value.identifier)"' Policyfile.lock.json
Updating nginx cookbooksWill relax constraints on:- nginxBuilding policy webExpanded run list: recipe[base::default], recipe[nginx::default], recipe[web::default]Caching Cookbooks...Using base >= 0.0.0 from gitUsing web >= 0.0.0 from pathInstalling nginx 11.2.0Lockfile written to /home/dev/cookbooks/web/Policyfile.lock.jsonPolicy revision id: 1d0c9b8a7f6e5d4c3b2a1908f7e6d5c4b3a29180f7e6d5c4b3a291807f6e5d4cPolicyfile.lock.json | 14 +++++++-------1 file changed, 7 insertions(+), 7 deletions(-)base 1.4.2 5f0e1a9c7d3b46528e0a1c7d9b3e5f2a4c6d8e01nginx 11.2.0 7b4c81df3e6a0592a3f0d7c9e5b1428f6c0d9a2eweb 0.3.1 c81df3e6a05926b2f0d7c9e5b1428f6c0d9a2e7b
Read that output the way a reviewer should. The base and web identifiers are character for character what they were before, so nothing about those cookbooks moved. Only nginx changed, 11.1.2 to 11.2.0, with a new identifier. Seven lines of the lock changed. A pull request that claims to be a security patch for one cookbook and touches seven lines is telling the truth. One that touches two hundred is doing something else, and you can see that before it reaches a machine. Push the new revision to dev, let it soak, then promote the same lock to staging and prod.
chef update reaches out to Supermarket it takes whatever the constraint allows at that moment, including a version published twenty minutes ago by an account that was compromised last night. The lock faithfully writes down that cookbook's identifier and then defends it forever. That is correct behaviour and it is not protection. The protection is a human reading the lock diff, plus running the new revision through a policy group where a mistake costs you nothing. Watch the scm_info block too when you source cookbooks from git, since SCM here means source control management: it records working_tree_clean and published, and a lock built from a dirty working tree or an unpushed commit will push perfectly happily, leaving you with a production revision whose source nobody else can fetch.Proving the Node Ran What You Shipped
Pinning you cannot verify is a comfortable story rather than a control. Chef Infra Client names the revision it is using near the top of every run, before it touches anything on the box. Grab it from a node that should already have converged.
sudo chef-client
Chef Infra Client, version 18.4.12Patents: https://www.chef.io/patentsInfra Phase startingUsing Policyfile 'web' at revision '8e7d6c5b4a3928170f6e5d4c3b2a1908f7e6d5c4b3a291807f6e5d4c3b2a1908'Resolving cookbooks for run list: ["base::default", "nginx::default", "web::default"]Synchronizing cookbooks:- base (1.4.2)- nginx (11.1.2)- web (0.3.1)Installing cookbook gem dependencies:Compiling cookbooks...Converging 9 resourcesRecipe: base::default* apt_update[periodic] action periodic (up to date)* chef_client_systemd_timer[chef-client] action add (up to date)Recipe: nginx::default* apt_package[nginx] action install (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)Recipe: web::default* directory[/srv/www/web] action create (up to date)* template[/etc/nginx/sites-enabled/web.conf] action create (up to date)* service[nginx] action nothing (skipped due to action :nothing)Running handlers:Running handlers completeInfra Phase complete, 0/9 resources updated in 04 seconds
That revision string is the one chef show-policy printed for the prod group, which closes the loop: the artifact you built on a workstation is the artifact converging on the machine. If the two differ, the node has not run since you pushed, and its subscription is the next thing to check.
knife node show web-01.acme.internal
Node Name: web-01.acme.internalEnvironment: _defaultFQDN: web-01.acme.internalIP: 10.20.4.11Run List:Roles:Recipes: base::default, nginx::default, web::defaultPlatform: ubuntu 22.04Tags:
The empty Run List catches everyone once. It is empty because the run-list is no longer a property of the node. It lives inside the policy revision, and the node object records only what it actually ran, which is the Recipes line written back after the converge. The Environment: _default line is the other half of this lesson's title. Chef environments still exist as objects, nodes still belong to one, and cookbook version constraints set on an environment are ignored the moment a node is on a policy. If you are midway through a migration and quietly relying on an environment pin to hold something back, it is holding nothing.
What This Costs You
Every change is now an artifact and a push. You cannot edit a role on the server to calm an incident at 3am, and the first time you need to, it will genuinely hurt. The honest answer is that the emergency fix has the same shape as any other release: change the code, update the one cookbook, push to a group, promote. Practise that on a quiet Tuesday afternoon so it is muscle memory when the pager goes off. Meanwhile the server accumulates every revision you ever pushed, plus the cookbook artifacts behind them, because nothing is deleted when a group moves on. chef show-policy --orphans lists the revisions no group points at. chef clean-policy-revisions deletes them, and chef clean-policy-cookbooks then removes artifacts no surviving revision needs. Run both on a schedule or watch your Chef Infra Server's disk fill up.
A few more commands round out the kit. chef diff staging...prod fetches both revisions from the server and shows what actually differs between them, which beats reasoning about it from memory. The three dots are load bearing, because two names separated by a space are read as a Policyfile path followed by a group. chef export /tmp/web-artifact --archive writes the whole locked policy, cookbooks included, into a tarball for an airgapped network, and chef push-archive takes a policy group and that archive file to load it onto a server on the other side. include_policy lets one Policyfile pull in another by revision id, so a platform team can own a base policy that application teams build on without copying its run-list around. On CINC Workstation (CINC Is Not Chef, the trademark free rebuild of the same source), all of this behaves the same under cinc-prefixed binaries such as cinc-client, and the Policyfile.rb and lock formats are unchanged.
Put chef show-policy in whatever you run in the first minute of an incident. Four lines of output tell you which revision each group is sitting on, and the revision id in the client log on the affected box tells you whether that node has caught up yet. Together they turn "something changed" into a specific Git commit, usually before everyone has finished joining the call.
cookbook 'nginx', '~> 11.1' and you ran chef install last month. What decides which nginx a production node converges with today?chef install, and git diff Policyfile.lock.json shows no change at all. Why?default_source only says which shop to buy from.chef push prints a success line, but production nodes keep converging on the old revision. chef show-policy prints:
web
===
* dev: 1d0c9b8a7f
* staging: 8e7d6c5b4a
* prod: a91f4d8c2e
* prd: 8e7d6c5b4a
What happened?prod, then clean up with chef delete-policy-group prd.Using, never treated as a failure.Try this
Run chef install 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: chef push invents policy groups out of thin air. Check that on your own systems before you need to, because it is cheaper to find on a quiet afternoon than during an incident.