Configuration as Code
Puppet ·
Ansible ·
Terraform
— What's the difference and when to use what? —
FinistDevs · 2026
Welcome — quick intro to the topic: how we manage infrastructure as code.
Three tools compared: Puppet, Ansible, Terraform — what each is for, when to pick which.
Goal: not "which is best" but understanding their distinct roles.
Arnaud Prémel-Cabic
Tech Lead @ OVHCloud
arnaud.premel-cabic@ovhcloud.com
Tech Lead at OVHcloud — work with these tools daily.
Keep it short, get to the content.
"It works on my server."
Why doesn't it work here when it works everywhere else?
The classic excuse — everyone's heard it (or said it).
Hook: the real problem isn't the code, it's the un-managed environment.
Sets up the whole talk: configuration we can't reproduce.
You have a server. It works.
Great.
One server, configured by hand — totally fine at this scale.
No tooling needed yet. The pain starts when you grow.
You have 10 servers.
10 SSH sessions
10 manual edits
10 chances to make a mistake
Manual edits don't scale — repetition breeds error.
Did you apply the change to all 10? Identically? You can't be sure.
$
ssh root@…
$
vim /etc/…
ssh
ssh
ssh
Now you have 100 servers.
Some 2 years old. Some a few months. Some brand new.
None of them are exactly alike.
Different ages, different patch levels, manual tweaks over time.
Point at the snowflakes/warnings in the diagram — each box is subtly different.
This is the reality of a hand-managed fleet.
!
❄
· · ·
Unique. Unreproducible. Undocumented.
Welcome to configuration drift.
"Snowflake servers" — unique, fragile, impossible to recreate.
The "this is fine" meme — we've all normalized this chaos.
Lighten the mood, then pivot to the consequences.
Configuration drift is silent…
Can't reproduce the bug locally
Can't scale reliably
Can't onboard a new server without fear
Define drift: state slowly diverging from intent, T0 → T2 in the diagram.
It's silent — no alarm goes off until prod breaks.
These four pains are why we need Configuration as Code.
T0
T1
T2
ALL IDENTICAL
DRIFTING…
!
!
!
CHAOS
What if your infrastructure was just… code?
Your AI assistant can write it*. You still need to understand what it deploys.
{ }
deploy
*Like this presentation 🤖
The pitch: describe infra in files, deploy reproducibly.
Aside on AI: it can write the code, but you must understand what it deploys — own the result.
Fun fact: this deck itself was built with AI assistance.
Configuration as Code
Machine-readable files. Version-controlled. Automated.
Three core benefits: reproducible, versionable, auditable.
Same input → same result. Git history = change log. Know who changed what & when.
This is the foundation all three tools share.
Reproducible
Same input → same result
Versionable
Track every change
Auditable
Who changed what & when
Meet the three musketeers of infrastructure.
Puppet ·
Ansible ·
Terraform
Each solves a different problem.
Introduce the three: Puppet, Ansible, Terraform.
Tease the punchline early: they're complementary, not rivals.
We'll go through them in deploy order: provision → configure → enforce.
Terraform
Start here. Before you configure a server, you need to have one.
First layer: provisioning. You can't configure what doesn't exist.
Terraform's job is creating the infrastructure itself.
What is Terraform?
Infrastructure as Code tool for provisioning cloud resources
Created by HashiCorp in 2014
Written in Go
BUSL 1.1 license since 2023 (was MPL)
The IaC standard for cloud provisioning.
HashiCorp, 2014, Go. Flag the 2023 license change — we'll come back to it (OpenTofu).
HCL: HashiCorp Configuration Language
Declarative, human-readable — pure JSON works too.
Declarative: you describe the desired end state, not the steps.
HCL is the common language across all HashiCorp tools.
The Terraform workflow
terraform plan — preview what will change
Review — validate the plan before proceeding
terraform apply — create or update resources
terraform destroy — tear everything down
Key safety feature: plan lets you preview before anything changes.
Always review the plan — this is what makes Terraform safe in prod.
apply is idempotent; destroy cleanly removes everything it created.
HCL Code
plan
review
apply
Resources
Terraform remembers what it built.
The .tfstate file maps code to real-world resources
Store it remotely — never commit it to Git
May contain sensitive values: credentials, tokens, secrets
Handle with care.
State is how Terraform knows what it already built — maps code to real resources.
Two big gotchas: store it remotely (team access + locking), never commit it (secrets inside).
Lost or corrupt state = Terraform loses track of reality.
main.tf
network.tf
dns.tf
Configuration
.tfstate
state mapping
Source of Truth
VM
VNet
DNS Zone
Real Resources
One tool. Every API.
1000+ providers — OVHcloud, Scaleway, Clever Cloud, AWS, Cloudflare, Kubernetes…
Not just cloud — DNS, monitoring, CI/CD, anything with an API
If it has an API, there's a Terraform provider for it.
The real power: one workflow for everything, not just one cloud.
Examples beyond cloud: DNS records, GitHub repos, monitoring dashboards.
Providers are the plugin ecosystem that makes Terraform universal.
# main.tf
terraform {
required_providers {
openstack = { source = "terraform-provider-openstack/openstack", version = "~> 3.0" }
ovh = { source = "ovh/ovh", version = "~> 2.0" }
}
}
resource "openstack_compute_instance_v2" "web" {
name = "finistdevs-web"
image_name = "Debian 13"
flavor_name = "b3-8"
network { name = "Ext-Net" }
}
resource "ovh_domain_zone_record" "web" {
zone = "example.com"
subdomain = "finistdevs"
fieldtype = "A"
target = openstack_compute_instance_v2.web.access_ip_v4
}
Real OVHcloud example: spin up an instance, then point a DNS record at it.
Note the implicit dependency — the DNS record references the instance's IP, so Terraform orders them automatically.
Two different providers (OpenStack + OVH) working together in one file.
Version syntax: ~> is the pessimistic constraint operator. ~> 2.0 allows any 2.x (>= 2.0, < 3.0) — patch and minor updates, but never a breaking major bump. ~> 2.13.0 would pin tighter, allowing only 2.13.x.
$ terminal
$ terraform init
Initializing provider plugins...
- Finding terraform-provider-openstack/openstack versions matching "~> 3.0"...
- Finding ovh/ovh versions matching "~> 2.0"...
- Installing terraform-provider-openstack/openstack v3.4.0...
- Installing ovh/ovh v2.13.1...
Terraform has been successfully initialized!
$ terraform plan
...
Plan: 2 to add, 0 to change, 0 to destroy.
$ terraform apply
...
openstack_compute_instance_v2.web: Creating...
openstack_compute_instance_v2.web: Creation complete after 45s [id=abc-123]
ovh_domain_zone_record.web: Creating...
ovh_domain_zone_record.web: Creation complete after 3s [id=456]
Apply complete! Resources: 2 added, 0 changed, 0 destroyed.
Walk the lifecycle: init (download providers) → plan → apply.
"2 added, 0 changed, 0 destroyed" — exactly the diff you reviewed, nothing more.
Re-running apply with no changes does nothing — that's idempotence.
Each ... on the slide hides real output — call it out so nobody thinks Terraform is this terse.
The ... after plan: a full per-resource +/- diff of every attribute Terraform will set — this is what you actually review.
The ... after apply: Terraform replays that same plan, then pauses for an interactive "yes" prompt — skip it with -auto-approve in CI.
I also trimmed init: real output adds backend init, "Installed … (signed by …)" lines, and the .terraform.lock.hcl being written.
HashiCorp changed Terraform's license.
BUSL 1.1 instead of MPL — no longer truly open-source
The community responded: OpenTofu , now a CNCF project
Drop-in for migration. Diverging features. Community-driven.
2023: HashiCorp moved to BUSL 1.1 — restricts commercial competitors, not truly open-source anymore.
Community forked it: OpenTofu, accepted into the Linux Foundation in 2023, a CNCF project since 2025.
Practical takeaway: drop-in compatible, swap the binary. Worth knowing for licensing-sensitive orgs.
Terraform at scale needs a platform.
Terraform Enterprise / HCP Terraform — remote state, RBAC, audit logs
Spacelift — GitOps-first CI/CD for Terraform and OpenTofu
Atlantis — open-source, plan & apply from pull requests
env0, Scalr — SaaS with policy & cost management
Running Terraform from a laptop doesn't scale to a team.
You need: shared/locked state, RBAC, audit, plan-on-PR, policy & cost guardrails.
Range from SaaS (HCP, Spacelift, env0, Scalr) to self-hosted open-source (Atlantis).
Ansible
Your servers are provisioned. Now make them do something.
Second layer: configuration. The VMs exist — now install and set things up.
Transition: Terraform built the box, Ansible makes it useful.
What is Ansible?
Agentless automation tool for configuration and orchestration
Created by Michael DeHaan in 2012
Acquired by Red Hat in 2015
Written in Python — GPLv3 license
Key differentiator: agentless — nothing to install on targets.
Red Hat backed, Python, genuinely open-source (GPLv3) — contrast with Terraform's BUSL.
Push-based. Runs over SSH.
YAML playbooks run tasks in order, across any number of hosts
Nothing to install on target servers — just Python + SSH
Idempotent modules — same playbook runs safely again and again
Push model: control node connects out over SSH and runs tasks — point at the diagram.
"Agentless" = just needs Python + SSH on the target, no daemon.
Idempotent modules: re-running is safe, only changes what's needed.
>_
Control Node
PUSH
SSH
Managed Hosts
no agent required
# playbook/webserver.yml
- name: Configure web server
hosts: webservers
become: true
tasks:
- name: Install nginx
ansible.builtin.package:
name: nginx
state: present
- name: Deploy nginx config
ansible.builtin.template:
src: nginx.conf.j2
dest: /etc/nginx/nginx.conf
notify: Restart nginx
handlers:
- name: Restart nginx
ansible.builtin.service:
name: nginx
state: restarted
Plain YAML — tasks run top to bottom against the "webservers" group.
become: true = run as root (sudo).
Handlers are the neat bit: restart nginx only if the config actually changed.
The .j2 in nginx.conf.j2 = Jinja2, Ansible's templating engine. The template module renders it on the control node — {{ variables }}, {% if %}/{% for %} logic, filters like {{ value | default(...) }} — then ships the result to dest.
That's how one template serves many hosts: same file, per-host values from inventory/group_vars.
Who runs where? The inventory.
Static — a hand-written INI/YAML file of hosts & groups. Simple, versioned, ideal for stable fleets.
Dynamic — a plugin builds the host list at runtime. Best source here: the Terraform state we just wrote — Ansible configures exactly what Terraform provisioned.
# inventory.yml — static
webservers:
hosts:
web-01:
ansible_host: 10.0.0.11
web-02:
ansible_host: 10.0.0.12
# terraform.yml — dynamic (from TF state)
plugin: cloud.terraform.terraform_state
backend_type: local
backend_config:
path: ../terraform/terraform.tfstate
The -i flag on the next slide points here — Ansible needs to know which hosts to target.
Static : explicit list checked into Git. Predictable, but you maintain it by hand.
This is the Terraform → Ansible handoff : Terraform creates the instance and records it in .tfstate (remember slide 16); the cloud.terraform.terraform_state plugin reads that same state and turns each resource into an Ansible host. One source of truth — no second host list to keep in sync.
Other dynamic sources exist too — cloud plugins like openstack.cloud.openstack or AWS/OVHcloud query the provider API directly. keyed_groups build groups from tags/metadata.
Provision with Terraform, then immediately configure with Ansible against the freshly-created hosts — that's the combined workflow we land on at the end.
$ terminal
$ ansible-playbook -i inventory playbook/webserver.yml
PLAY [Configure web server] ***************************************************
TASK [Gathering Facts] ********************************************************
ok: [finistdevs-web]
TASK [Install nginx] **********************************************************
changed: [finistdevs-web]
TASK [Deploy nginx config] ****************************************************
changed: [finistdevs-web]
RUNNING HANDLER [Restart nginx] ***********************************************
changed: [finistdevs-web]
PLAY RECAP ********************************************************************
finistdevs-web : ok=4 changed=3 unreachable=0 failed=0 skipped=0
"ok" vs "changed" — ok means already in desired state, changed means it acted. That's idempotence visible in the output.
The handler only fired because the config task reported "changed".
Run it again and everything would be "ok", changed=0.
Not just configuration. Operations.
Patch 200 servers tonight
Roll out a kernel upgrade with a canary strategy
Run a compliance audit across your whole fleet
The go-to tool for one-off tasks and recurring operations.
Ansible isn't only "set up a server once" — it shines for ad-hoc operations.
Imperative/orchestration angle: patching, rolling upgrades, canary, audits across the fleet.
This is where it differs most from Puppet's "always converge" model.
The community does the heavy lifting.
Ansible Galaxy — 10,000+ ready-made roles and collections
Don't write a playbook to install Docker from scratch — someone already did
$ ansible-galaxy install <namespace>.<role>
Galaxy = the package registry for reusable roles/collections.
Don't reinvent common setups — pull a battle-tested role (geerlingguy is the famous example).
Huge productivity multiplier — but it's a supply chain: roles run with privilege on your hosts.
Prefer trusted sources: Red Hat Certified collections and verified publishers (Automation Hub), or well-known community authors like geerlingguy. Be wary of unmaintained, low-download, single-author roles.
Pin versions in requirements.yml and skim the code before importing — treat it like any other dependency.
Ansible at scale: open-source vs enterprise.
AWX — open-source web UI, API, and scheduler
Ansible Automation Platform (Red Hat) — enterprise AWX with support
Semaphore — lightweight open-source alternative
Core engine remains GPLv3 — truly open-source.
At scale you want a UI/scheduler/RBAC layer on top of the CLI.
AWX (free) → Ansible Automation Platform (Red Hat, supported) is the main path; Semaphore is a lighter option.
Reassure: the core stays GPLv3 — no license rug-pull like Terraform.
Puppet
Your servers are configured. Now keep them that way.
Third layer: enforcement. Configured once isn't enough — drift creeps back.
Puppet's job: keep state correct continuously, forever.
What is Puppet?
Configuration management tool for enforcing system state
Created by Luke Kanies in 2005
Puppet Inc. acquired by Perforce in 2022
Written in Ruby and Clojure
The oldest of the three (2005) — pioneered config management.
Now owned by Perforce. We'll touch on what that means for the community later.
Pull, not push. Agents, not SSH.
Every 30 minutes, each puppet-agent polls the Puppet Server
Compiles a catalog and enforces it locally
Drift is corrected automatically.
Opposite model to Ansible: pull, not push — agents reach out to the server.
Every ~30 min the agent fetches a catalog and converges the node — point at the diagram.
This is the key idea: enforcement runs on a loop, not just at deploy.
catalog
Puppet Server
every 30 min
pull
agent
agent
agent
agent
agent
Managed Nodes
# manifests/webserver.pp
class webserver {
package { 'nginx':
ensure => installed,
}
file { '/etc/nginx/nginx.conf':
ensure => file,
content => template('webserver/nginx.conf.erb'),
notify => Service['nginx'],
}
service { 'nginx':
ensure => running,
enable => true,
}
}
Same nginx example as Ansible — compare the styles side by side.
Pure declarative: describe resources (package, file, service) and desired state, not steps.
notify chains the dependency: config change → restart service.
$ terminal
$ puppet agent -t
Info: Using environment 'production'
Info: Retrieving pluginfacts
Info: Caching catalog for finistdevs-web.example.com
Info: Applying configuration version '1713052408'
Notice: /Stage[main]/Webserver/Package[nginx]/ensure: created
Notice: /Stage[main]/Webserver/File[/etc/nginx/nginx.conf]/content:
--- /etc/nginx/nginx.conf
+++ /tmp/puppet-file20260413
Notice: /Stage[main]/Webserver/Service[nginx]/ensure: started
Notice: Applied catalog in 12.34 seconds
Agent run: fetch catalog → compare to actual → apply only the diffs.
This normally runs automatically every 30 min; -t is a manual trigger for demo.
Next run with no drift would report no changes.
Someone SSH'd in and changed something.
Puppet noticed. Puppet fixed it.
Continuous compliance — not just at deploy time. Every. 30. Minutes.
No manual remediation
The killer feature: self-healing. Someone hand-edits a file → Puppet reverts it next run.
Walk the loop in the diagram: drift detected → agent applies → compliant → repeat.
This is what "continuous compliance" means in practice.
!
Drift
detected
1
Agent
applies catalog
2
Compliant
3
continuous enforcement loop
Puppet: large fleets, zero drift.
Continuous compliance, auditability, and guaranteed state — at scale
Best suited for enterprises with hundreds or thousands of long-lived servers
Fewer SaaS options than Terraform or Ansible
Puppet Enterprise and Foreman are self-hosted. No managed cloud offering.
Sweet spot: large fleets of long-lived servers where drift control matters.
Trade-off vs the others: fewer SaaS options, more setup — it's self-hosted.
Honest framing: overkill for a handful of ephemeral cloud VMs.
The ecosystem outlives the company.
Vox Pupuli — 100+ open-source Puppet modules, community-maintained
OpenVox — an emerging open-source fork of the Puppet core
The community is strong, with or without Puppet Inc.
Addresses the "is Puppet dying?" worry after the Perforce acquisition.
The Perforce shift: in Nov 2024 Perforce announced it would stop shipping public open-source Puppet binaries. From early 2025, official packages move to a private location — access needs a developer license (capped at 25 nodes) or a commercial license, and ongoing development moved to internal/private repos.
Vox Pupuli couldn't accept the Puppet Core Developer EULA — its restrictions block testing and redistribution of the community modules.
So they forked: OpenVox started as a community package mirror (Overlook InfraTech), and Vox Pupuli shipped the first release on Jan 21, 2025 — OpenVox 8.11 is functionally equivalent to Puppet 8.11, fully open, no EULA. (Same playbook as Terraform → OpenTofu.)
Reassurance: the open ecosystem outlives any single vendor.
They're not competing. They're complementary.
Each solves a different layer of the same problem.
The payoff slide — the "vs" framing was a trap. They stack.
Terraform provisions → Ansible configures → Puppet enforces. Three layers.
"Which should I use?" → depends which layer of the problem you have.
Terraform
— Provision
VMs, networks, cloud resources
Ansible
— Configure
packages, services, app deployment
Puppet
— Enforce
continuous compliance, drift correction
A common production setup:
Terraform provisions the VM
Ansible configures it and deploys the app
Puppet continuously enforces compliance
Concrete recap of how they fit together end to end.
You don't have to use all three — but they layer cleanly when you do.
Pick by your actual need: just provisioning? Terraform. Ad-hoc ops? Ansible. Drift control? Puppet.