Configuration management
Provisioning a server is only half the job — something still has to install the right packages, drop the right config files, create the right users, and keep the right services running on it, forever, as the fleet grows and drifts. This page draws a hard line between that work and infrastructure as code, then compares the two dominant delivery models for doing it: agentless push tools like Ansible and agent-based pull tools like Puppet and Chef. By the end you should be able to say, for a given change, whether it belongs in your IaC layer or your config management layer, and why.
Infrastructure as code is the construction crew that pours the foundation, frames the house, and connects it to power and water — the shell has to exist before anyone can live in it. Configuration management is the move-in crew that arrives after: hanging the right curtains, stocking the right pantry items, setting the thermostat, and making sure the smoke detector actually has a battery in it. You wouldn't ask the framing crew to restock your pantry every time you run out of milk, and you wouldn't ask the move-in crew to pour a new foundation — each crew has a job, and confusing them is how houses end up half-built with groceries in the driveway.
Where provisioning stops and configuration starts
The line is commonly blurred because modern tools can technically do both, but the conceptual boundary is precise: infrastructure as code provisions resources — it calls a cloud or virtualization API to make a VM, a load balancer, a VPC, or a managed database exist — while configuration management configures software on a resource that already exists — installing packages, templating config files, managing users and permissions, and ensuring services are running in the desired state. Terraform asking AWS for an EC2 instance is provisioning; Ansible then SSHing into that instance to install nginx, template out /etc/nginx/nginx.conf, and start the service is configuration management. See infrastructure as code for the provisioning half of this story in detail.
The boundary gets genuinely fuzzy in two places. First, cloud-init and user-data scripts run at boot time and can do light configuration work, so teams sometimes skip a dedicated config management layer for simple VMs entirely. Second, tools overlap in practice: Ansible ships modules for provisioning cloud resources (amazon.aws.ec2_instance, for example), and Terraform's provisioner blocks can run remote shell commands — but the Terraform documentation itself calls provisioners "a last resort," precisely because reaching across the boundary breaks the clean separation both tools are built around. The practical rule that holds up: if the answer to "does this resource exist yet" changes, it's IaC; if the resource already exists and you're changing what's running on it, it's configuration management.
Push-based: a controller drives, agentless
In the push model — Ansible is the reference example — a control node connects outward to each managed host, typically over SSH (or WinRM for Windows), and executes a set of instructions there on demand. There is no persistent agent process running on the managed hosts; Ansible only needs Python present on the target and an SSH key or credential the controller can use. A run happens when a human or a CI job triggers it — ansible-playbook site.yml — and nothing happens on the fleet in between runs unless something triggers another run.
The trade-offs follow directly from that shape. Push is simple to reason about: state changes only when you deliberately push them, so there's no background process quietly reconciling things on its own schedule, and a new engineer can read a playbook top to bottom and know exactly what will happen. It's also easy to bootstrap — nothing to install on a thousand fresh hosts before you can manage them. The cost is that push doesn't self-heal: if someone manually edits a config file on a host at 2 a.m. and nobody re-runs the playbook, that host silently drifts out of desired state until the next scheduled or manual run catches it. Fleet-wide changes also fan out from a single controller, which becomes a real bottleneck and a real blast-radius concern at hundreds or thousands of hosts unless you tune forks and rollout batching (serial) carefully.
Pull-based: agents reconcile on their own schedule
In the pull model — Puppet and Chef are the reference examples — each managed host runs a persistent local agent that periodically (Puppet's default agent run interval is 30 minutes) contacts a central server, pulls down its assigned desired-state definition (a Puppet catalog or a Chef run list), and reconciles local state to match it without waiting to be told. Nothing needs to reach out to the host from the outside; the host reaches out on its own.
This buys continuous self-healing: a manually edited file gets reverted, or a stopped service gets restarted, automatically on the next agent run, with no human needing to notice and re-trigger anything. It scales horizontally almost for free, since each host does its own reconciliation work rather than a central controller doing it for everyone. The cost is real operational overhead: an agent to install, update, and keep alive on every host; a central server (Puppet Server, Chef Server) that becomes its own piece of infrastructure to run, secure, and scale; certificate-based trust between agent and server to manage; and less precise timing control, since "eventually, within the next reconciliation interval" is the default guarantee rather than "right now, in this exact order," though both tools support forcing an immediate run when you need one.
Push vs. pull is really a trade of control for resilience. Push gives you a single moment when change happens and a human decides when — easier to reason about, easier to gate behind a pipeline. Pull gives up that precise timing in exchange for continuous drift correction you don't have to remember to run. Neither is strictly better: many shops run push-based Ansible through their CI/CD pipeline for planned rollouts, and reach for a pull-based agent specifically on fleets where unauthorized drift is the bigger risk than deployment latency.
Desired state and idempotency: why playbooks are declarative, not scripts
The property that makes both models safe to run repeatedly is idempotency: running the same playbook, manifest, or cookbook against a host that's already in the desired state produces zero changes and zero errors, not a second copy of a user or a duplicated line in a config file. This is what separates a config management tool from a shell script. A bash script that runs useradd deploy fails the second time it runs, because it describes an action (create this user) with no awareness of current state. A config management task that says "ensure user deploy exists" describes a desired end state — the tool checks current state first and only acts if there's a gap, which is why these tools are called declarative rather than imperative.
The YAML below is a representative Ansible playbook task list — the same three-step shape (ensure package, template config, ensure service state) recurs across Ansible, Puppet, and Chef, just in different syntax. Every task here is idempotent: re-running this playbook against a host that already matches produces "ok" (no change) on every task, not an error.
- name: Configure nginx web server
hosts: webservers
become: true
vars:
nginx_worker_connections: 1024
tasks:
- name: Ensure nginx package is installed
ansible.builtin.package:
name: nginx
state: present # not "install" — declares the end state
- name: Template the nginx configuration
ansible.builtin.template:
src: nginx.conf.j2
dest: /etc/nginx/nginx.conf
owner: root
group: root
mode: "0644"
notify: Reload nginx # only fires the handler if this task changed something
- name: Ensure nginx service is running and enabled
ansible.builtin.service:
name: nginx
state: started
enabled: true
handlers:
- name: Reload nginx
ansible.builtin.service:
name: nginx
state: reloaded
Two mechanics are worth naming here because they show up under different names in every tool in this space. First, state: present / state: started is the desired-state declaration — you're never telling Ansible how to install nginx, only what "installed" should look like when it's done checking. Second, notify/handlers is a change-triggered pattern also present in Puppet (via notify => resource relationships) and Chef (via notifies): the reload only runs if the template task actually changed the file, so a no-op run doesn't needlessly bounce a healthy service.
Config drift: detecting when reality stops matching the playbook
Config drift is what happens between desired-state definition and actual host state whenever something outside the config management tool touches a host — a manual ssh fix during an incident, a package auto-updated by the OS, a file edited by hand and never committed back to the playbook. Left unchecked, drift is how "works on this one host" bugs and un-reproducible production incidents get created, because the host running in production quietly stops matching the source of truth in version control.
The two delivery models detect and correct drift differently, and this is the sharpest practical consequence of the push/pull split covered above:
- Pull-based agents correct drift automatically as a side effect of their normal reconciliation loop — a Puppet agent's next 30-minute run reverts an unauthorized change without anyone requesting it, which is a real operational safety net but can also mask who made a change and why.
- Push-based tools need drift detection run explicitly, since nothing happens on a host between runs. Ansible supports this with
--checkmode (a dry run that reports what would change without changing anything) combined with--diffto show the exact content delta, typically wired into a scheduled CI job that alerts on any detected drift rather than auto-correcting it.
Either way, the fix for drift is the same discipline: treat the playbook or manifest in version control as the only legitimate source of truth, route every change through it — including "quick" incident fixes, which should be replayed back into the playbook afterward — and never let an SSH session become a silent second author of production state.
A common failure mode is writing playbooks that look declarative but aren't idempotent underneath — a command or shell task in Ansible that runs echo "export PATH=..." >> ~/.bashrc will append a duplicate line on every single run, because shell and command are raw escape hatches with no built-in state check. Prefer a purpose-built module (lineinfile, blockinfile, template) whenever one exists, and if you must drop to shell, guard it with a creates:/removes: argument or an explicit conditional so re-running it is still a no-op.
1. A Terraform apply creates a new EC2 instance, and an Ansible playbook then installs and configures Postgres on it — which step is infrastructure as code and which is configuration management, and what's the general rule for telling them apart? 2. Contrast how Ansible and Puppet each learn that a change needs to be applied to a host — what triggers a run in each model? 3. What does it mean for a playbook task to be idempotent, and why does a raw useradd shell command fail that test while Ansible's user module doesn't? 4. Name one way push-based tools detect drift and one way pull-based tools handle it differently.
Check your answers
- The Terraform apply is infrastructure as code (it makes the instance exist); the Ansible run is configuration management (it configures software on a host that already exists). General rule: if the change affects whether the resource exists, it's IaC; if the resource already exists and you're changing what's running on it, it's configuration management.
- Ansible is push-based: a controller connects outward over SSH and runs the playbook only when a human or CI job triggers it. Puppet is pull-based: an agent on each host contacts the Puppet Server on its own periodic schedule (30 minutes by default) and reconciles state without being externally triggered.
- Idempotent means running the same task twice against a host already in the desired state produces no changes and no errors.
useradd deploydescribes an action and fails the second time because the user already exists; Ansible'susermodule describes a desired end state ("this user should exist") and checks current state first, so a second run is a safe no-op. - Push-based tools (Ansible) typically detect drift with an explicit dry run,
--checkcombined with--diff, often scheduled in CI to alert on differences. Pull-based tools (Puppet, Chef) don't need a separate detection step — their normal periodic agent run reconciles and corrects drift automatically as a side effect.