Infrastructure as code with Ansible and Puppet
Listen to this lesson
This episode is a study companion for CompTIA Linux+ XK0-006 and is not produced by or endorsed by CompTIA.
Why this matters
A machine configured by hand is a machine nobody can rebuild. The person who set it up has left, the steps were never written down, and the only record of why a sysctl is set to that value is the value itself. Everyone in operations has met that server, and it is usually the one that must never be rebooted.
Infrastructure as code is the answer: the configuration is a file in version control, the file is the truth, and the machine is a disposable consequence of it. Rebuilding is running the code again. The audit trail is git log.
That is the idea; the rest of this lesson is the tools that implement it, and the distinctions the exam draws between them.
The lesson
What infrastructure as code actually changes
Three properties follow from putting configuration in a repository, and they are the reason the practice took over:
Reproducible. Fifty identical machines, or the same machine again next year, because the description is executable rather than a wiki page that drifted.
Reviewable. A change to production is a diff somebody approved. Version control is not an accessory here — it is what makes the rest true. Without it you have scripts, and scripts nobody reviewed.
Recoverable. The disaster recovery plan stops being a document and becomes a command.
The mental shift is from pets to cattle: a server you have named, tuned by hand and nurse back to health, versus one you destroy and recreate because recreating is a two-minute operation with a known outcome.
The other property to hold onto is idempotence — running the code twice produces the same result as running it once. A shell script that appends a line to a config file appends it again on the second run. A properly written Ansible task checks whether the line is present and does nothing if it is. That difference is most of what separates a configuration management tool from a script.
Ansible
Ansible is the tool CompTIA leans on hardest, and the one you are most likely to meet on a Linux team.
Its defining property is that it is agentless. There is no daemon to install on the machines it manages: Ansible connects over SSH, copies a small Python module across, runs it, collects the result and deletes it. The requirements on a managed host are SSH and Python, both of which are already there.
The agent/agentless distinction is worth being able to argue both ways, because the exam may ask for either side:
| Agentless (Ansible) | Agent-based (Puppet) | |
|---|---|---|
| Install on host | Nothing | A daemon, and its certificate |
| Runs when | You run it | On a schedule, by itself |
| Drift | Corrected next time you run | Corrected continuously |
| Scale limit | SSH connections, in parallel | The agents do their own work |
| Firewall | Outbound SSH from control node | Inbound to the agent's port |
Agentless is far simpler to adopt and to reason about. Agent-based enforces continuous compliance without anyone running anything, which is what you want across ten thousand hosts.
Inventory is the list of what Ansible manages, and the grouping in it is what makes playbooks readable:
# /etc/ansible/hosts
[webservers]
web01.example.com
web02.example.com
[dbservers]
db01.example.com
[production:children]
webservers
dbservers
[webservers:vars]
http_port=8080
# inventory.yml -- the same thing in YAML
webservers:
hosts:
web01.example.com:
web02.example.com:
vars:
http_port: 8080
A dynamic inventory script queries a cloud provider instead, so the list is whatever is actually running rather than whatever someone last edited.
Ad hoc commands are one-off tasks with no playbook — the entry point, and genuinely useful for a day's work:
ansible all -m ping
ansible webservers -m command -a "uptime"
ansible webservers -m dnf -a "name=nginx state=latest" --become
ansible all -m setup # dump every fact about every host
-m names the module, -a its arguments, --become escalates with sudo. "Restart nginx on all web servers" needs no file at all.
Playbooks are the declarative form: YAML describing the state you want.
---
- name: Configure web servers
hosts: webservers
become: true
vars:
http_port: 8080
tasks:
- name: Install nginx
ansible.builtin.dnf:
name: nginx
state: present
- name: Deploy the configuration
ansible.builtin.template:
src: nginx.conf.j2
dest: /etc/nginx/nginx.conf
owner: root
mode: "0644"
validate: nginx -t -c %s
notify: Reload nginx
- name: Start and enable nginx
ansible.builtin.service:
name: nginx
state: started
enabled: true
handlers:
- name: Reload nginx
ansible.builtin.service:
name: nginx
state: reloaded
ansible-playbook site.yml
ansible-playbook site.yml --check --diff # dry run, showing what would change
ansible-playbook site.yml --limit web01
ansible-playbook site.yml --tags nginx
Read state: present as the whole philosophy. You are not saying "install nginx", you are saying "nginx should be installed" — and a task that finds it already installed reports ok and does nothing. That is idempotence in practice, and it is why --check can tell you truthfully what a run would change.
The handler is the other piece worth understanding: it runs only if a task reported changed, and only once at the end. So the config is reloaded when the config actually changed, and not on every run.
Ansible is written in YAML, which brings YAML's one cruelty: indentation is significant and tabs are forbidden. Every Ansible beginner loses an hour to a tab character. ansible-lint catches it, along with a great deal else.
Modules are the units of work — dnf, apt, copy, template, service, user, file, lineinfile, git, uri. There are thousands. The rule of thumb: use a module rather than command or shell, because modules are idempotent and know how to report whether they changed anything, while a shell command runs every time and always claims to have changed something.
Collections are how modules are packaged and distributed since Ansible 2.10 — a bundle of modules, roles and plugins, named namespace.collection:
ansible-galaxy collection install community.general
ansible-galaxy collection install ansible.posix
ansible.builtin is the collection that ships with Ansible itself, which is why the tasks above are written ansible.builtin.dnf rather than just dnf. Both work; the fully qualified name is unambiguous.
Facts are what Ansible discovers about a host before it runs anything — the setup module gathers hundreds of them:
- name: Use the right package manager
ansible.builtin.package:
name: nginx
state: present
when: ansible_facts['os_family'] in ['RedHat', 'Debian']
- name: Only on hosts with real memory
ansible.builtin.debug:
msg: "{{ ansible_facts['memtotal_mb'] }} MB on {{ ansible_facts['hostname'] }}"
when: ansible_facts['memtotal_mb'] > 4096
Facts are what let one playbook serve a mixed fleet. They also cost time — gather_facts: false at the top of a playbook that does not use them noticeably speeds up a large run.
Puppet
Puppet is the agent-based counterpart, and it approaches the problem differently enough to be worth contrasting.
An agent runs on every managed node, waking every thirty minutes by default. It sends its facts to the Puppet server, receives a compiled catalogue of the state it should be in, applies it, and reports back. Nobody triggers anything; drift is corrected continuously.
Classes are Puppet's unit of grouping — a named block of resources you include on a node:
class nginx_server {
package { 'nginx':
ensure => installed,
}
file { '/etc/nginx/nginx.conf':
ensure => file,
source => 'puppet:///modules/nginx/nginx.conf',
require => Package['nginx'],
notify => Service['nginx'],
}
service { 'nginx':
ensure => running,
enable => true,
}
}
node 'web01.example.com' {
include nginx_server
}
Note require and notify: Puppet builds a dependency graph and works out the order itself, where an Ansible playbook runs top to bottom. That is a real philosophical difference — declare relationships, not sequence.
Certificates are how Puppet establishes trust, and this is the operational detail the exam asks about. Each agent generates a key pair and sends a signing request to the server; the server's built-in CA signs it; from then on the mutual TLS between them is authenticated.
puppetserver ca list # pending requests
puppetserver ca sign --certname web01.example.com
puppetserver ca revoke --certname old01.example.com
puppet agent --test # run once now, verbosely
Two consequences you will meet. A newly built node does nothing until its certificate is signed — the commonest "Puppet isn't working" cause. And rebuilding a host with the same name fails, because the server still holds the old certificate for that name; you must revoke and clean it first. Both follow from certificates being an identity, which is the point.
OpenTofu and Terraform
Ansible and Puppet configure machines that exist. OpenTofu provisions the machines themselves — networks, VMs, load balancers, DNS records, storage buckets — from a description.
OpenTofu is the open-source fork of Terraform, created in 2023 when HashiCorp changed Terraform's licence away from open source. It is drop-in compatible, governed by the Linux Foundation, and it is the name CompTIA has chosen to use — which itself tells you something about where the industry landed.
Its two core concepts are on the objectives explicitly.
A provider is the plugin that knows how to talk to one platform's API — AWS, Azure, Google Cloud, libvirt, Proxmox, Kubernetes, Cloudflare. It is what translates the description into API calls:
terraform {
required_providers {
aws = {
source = "hashicorp/aws"
version = "~> 5.0"
}
}
}
provider "aws" {
region = "eu-west-2"
}
A resource is one thing you want to exist:
resource "aws_instance" "web" {
ami = "ami-0abcdef1234567890"
instance_type = "t3.micro"
subnet_id = aws_subnet.public.id
tags = {
Name = "web01"
}
}
resource "aws_subnet" "public" {
vpc_id = aws_vpc.main.id
cidr_block = "10.0.1.0/24"
}
Notice aws_subnet.public.id referenced from the instance. Those references build a dependency graph, so OpenTofu creates the subnet before the instance without being told the order — the same declarative idea as Puppet's require.
The workflow is three commands:
tofu init # download the providers
tofu plan # what WOULD change -- read this every time
tofu apply # make it so
tofu destroy # tear it all down
plan is the command that matters. It compares your description to reality and prints exactly what it will create, change or destroy. A plan saying 1 to add, 0 to change, 0 to destroy is a different afternoon from one saying 1 to add, 0 to change, 14 to destroy — and the second happens when an apparently innocent attribute change forces a resource to be replaced. Read the plan. Every time.
Unattended deployment and Kickstart
The bottom of the stack is a machine with nothing on it. Unattended deployment is how it gets an operating system with nobody at the console.
Kickstart is the RHEL-family answer, covered in full in the installation lesson: a file answering every installer question, fetched at boot.
inst.ks=http://provisioning.example.com/ks/web.cfg
Debian's equivalent is preseed; Ubuntu Server uses autoinstall YAML; Windows has unattend.xml. And in the cloud there is no installer at all — you launch a prepared image and cloud-init configures it on first boot.
The complete chain, which is the shape worth carrying out of this lesson:
PXE boot -> Kickstart installs the OS -> Ansible configures it
-> OpenTofu created the VM it runs on
Each layer hands off to the next, and none of them require a human.
Deployment orchestration and Kubernetes
Deployment orchestration is the layer above: not "configure this machine" but "keep this application running across these machines" — scheduling workloads, replacing failures, scaling with load, routing traffic.
Kubernetes is the dominant implementation. You describe the desired state in YAML; a control loop compares it to actual state and works continuously to close the gap:
apiVersion: apps/v1
kind: Deployment
metadata:
name: web
spec:
replicas: 3
selector:
matchLabels:
app: web
template:
metadata:
labels:
app: web
spec:
containers:
- name: nginx
image: nginx:1.25
ports:
- containerPort: 80
kubectl apply -f deployment.yml
kubectl get pods
kubectl scale deployment web --replicas=5
Three replicas means three: kill one and Kubernetes starts another within seconds, because the loop notices the difference. That is the same declarative principle as state: present, applied to a running fleet rather than a file.
Kubernetes proper is the next lesson. What matters here is where it sits — OpenTofu builds the cluster, Ansible configures the nodes, Kubernetes decides what runs on them.
CI/CD
Continuous integration / continuous deployment is the automation that connects the repository to the running system: a push triggers a pipeline that tests the change and, if it passes, applies it.
For infrastructure code that means a pull request runs ansible-lint and tofu plan, a reviewer reads the plan, and merging to main runs tofu apply. Nobody applies anything from a laptop, and every change to production has a diff, an approval and a log entry.
This is the practice that makes infrastructure as code more than a filing system, and it is the subject of the next lesson.
On the exam
- Infrastructure as code means the configuration lives in version control and the machine is rebuildable from it. Idempotence — same result on the second run — is the property that distinguishes it from scripting.
- Ansible is agentless, over SSH plus Python, push-based. Puppet uses an agent that pulls a catalogue on a schedule and corrects drift continuously.
- Ansible: inventory lists and groups hosts; ad hoc runs a single module; playbooks are declarative YAML; modules do the work; collections package them; facts are gathered host data used in conditionals.
- Prefer a module to
command/shell— modules are idempotent. - Puppet: classes group resources, and certificates authenticate agents. A new node does nothing until its certificate is signed.
-
OpenTofu is the open-source Terraform fork. A provider talks to a platform's API; a resource is one thing to create.
init→plan→apply, and always read the plan. - Kickstart drives unattended installation; cloud-init does the equivalent for cloud images.
- Deployment orchestration schedules workloads across machines; Kubernetes is the standard, reconciling actual state to declared state.
- CI/CD applies infrastructure changes from the repository rather than from a laptop.
Practise what you just read
1. What distinguishes a configuration management tool from a shell script that performs the same steps?
Select one
Show answer
D. A script that appends a line to a config file appends it again on the second run. An Ansible task declares the state -- the line should be present -- checks, and does nothing if it already is. That is what lets you run the whole playbook safely at any time, and what makes --check able to report truthfully what would change.
2. What does it mean that Ansible is agentless?
Select one
Show answer
D. Ansible copies a small Python module to the target over SSH, runs it, collects the result and deletes it. There is no long-running daemon to install, patch or attack. Puppet takes the opposite approach with an agent that pulls a catalogue on a schedule -- which costs deployment effort and buys continuous drift correction.
3. In Ansible, what is an inventory?
Select one
Show answer
C. The inventory names the hosts and groups them -- webservers, dbservers, production -- and grouping is what makes playbooks readable, since a play targets a group rather than a list of names. A dynamic inventory queries a cloud provider instead, so the list is what is actually running rather than what someone last edited.
9 more questions on this objective are part of the full course.
Hands-on labs
Part of the free CompTIA Linux+ XK0-006 course — 48 lessons and 82 hands-on labs.