Infrastructure as code: Ansible first steps
Setting up one server by hand is fine. Setting up ten the same way, every time, with nothing forgotten, is not. Infrastructure as code means you describe how your servers should look in text files, keep them in git, and let a tool make it so. Ansible is the friendliest place to start: no agent to install on the servers, just SSH and YAML. And it handles Rocky and Ubuntu side by side, which is this whole site's favourite trick.
You will learn
- What infrastructure as code is, and where Ansible, Terraform, Puppet and friends fit
- The inventory: which machines, in which groups
- Ad-hoc commands:
ansible all -m ping,-a 'uptime', facts - Playbooks: plays, tasks, modules,
become, variables, templates - Handling both families with facts and
when: - Idempotence,
--checkand--diff
Why “as code”?
- Repeatable: the tenth server is set up exactly like the first. So is the replacement when one dies at 3 am.
- Reviewable: a change to your servers is a git commit that someone can read before it happens.
- Self-documenting: “how is the web server configured?” The answer is the playbook.
| Tool | Best at | How it works |
|---|---|---|
| Ansible | Configuring machines that exist: packages, files, services, users | Push over SSH, YAML playbooks, no agent (Red Hat) |
| Terraform / OpenTofu | Creating infrastructure: cloud servers, networks, DNS, databases | Declarative HCL files, talks to cloud APIs |
| Puppet, Chef, Salt | Keeping big fleets configured all the time | An agent on every machine pulls its config |
| cloud-init | First boot of a new cloud server | Runs once, from user data |
A common combination: Terraform creates the servers, and Ansible configures them.
Install Ansible (on the control machine only)
sudo dnf install epel-release sudo dnf install ansible
ansible-core (AppStream) has the engine and the ansible.builtin modules. The ansible package (EPEL) adds the community collections, like ansible.posix.firewalld.
sudo apt install ansible
Also available: pipx install ansible for the newest version.
The machines you manage (managed nodes) need nothing but SSH and Python, which Rocky and Ubuntu both have. That's what “agentless” means.
The inventory: which machines
[web] # a group called "web" web1 ansible_host=192.168.1.61 # Rocky 9 web2 ansible_host=192.168.1.62 # Ubuntu 24.04 [web:vars] # variables for everyone in the group ansible_user=student
Every host is also in the built-in group all. ansible.cfg in the project folder tells Ansible where the inventory is, so you don't have to type -i inventory.ini every time. Check what Ansible sees with ansible-inventory --graph.
Ad-hoc commands: one task, many machines
ansible all -m ping # can we reach them? (SSH + Python, not ICMP ping) ansible web -a 'uptime' # run a command everywhere ansible web -a 'cat /etc/os-release' ansible web -m setup -a 'filter=ansible_distribution*' # facts ansible web -b -m package -a 'name=tree' # -b = become root (sudo)
Facts are what Ansible learns about each machine when it connects: its OS family, distribution, version, IP address, memory and more. They're how one playbook can do the right thing on Rocky and on Ubuntu.
A playbook: the whole setup in one file
--- - name: Set up the web servers # a PLAY: which hosts, and what to do there hosts: web become: true # run the tasks with sudo vars: site_title: "CHT Tickets" tasks: - name: Install Apache (Rocky) # a TASK: one module, with arguments ansible.builtin.dnf: name: httpd state: present when: ansible_facts['os_family'] == "RedHat" - name: Install Apache (Ubuntu) ansible.builtin.apt: name: apache2 state: present update_cache: true when: ansible_facts['os_family'] == "Debian" - name: Put our home page in place ansible.builtin.template: src: templates/index.html.j2 # {{ site_title }} gets filled in dest: /var/www/html/index.html mode: "0644" - name: Start Apache now and at every boot ansible.builtin.service: name: "{{ 'httpd' if ansible_facts['os_family'] == 'RedHat' else 'apache2' }}" state: started enabled: true - name: Open the firewall for HTTP (Rocky) ansible.posix.firewalld: service: http permanent: true immediate: true state: enabled when: ansible_facts['os_family'] == "RedHat"
Look at the modules. You don't say “run dnf install httpd”, you say “httpd should be present”. You don't say “start Apache”, you say it should be started and enabled. That's declarative: you describe the result, and the module works out what, if anything, needs doing.
The template is a normal file with {{ variables }}, written in Jinja2:
<h1>{{ site_title }}</h1>
<p>Served by {{ inventory_hostname }}, running {{ ansible_facts['distribution'] }} {{ ansible_facts['distribution_version'] }}.</p>
Idempotence: run it again and again
Run the playbook twice. The first time, the tasks say changed. The second time, they say ok with changed=0, because everything is already the way the playbook describes. That property is called idempotence, and it's what makes Ansible safe: you can run the playbook every day (or from CI) to fix anything that drifted.
ansible-playbook site.yml --check --diff # dry run: what WOULD change, and the file diffs ansible-playbook site.yml # do it ansible-playbook site.yml -l web1 # only on web1 (--limit)
--check can't see the future
In check mode nothing really happens, so a later task can fail because it depends on an earlier one. For example, the template's folder /var/www/html doesn't exist yet because Apache was only pretend-installed. On a fresh server, expect that. On a server that's already set up, --check --diff is the best preview there is.
command and shell are the last resort
ansible.builtin.command runs anything, but it can't know whether it needs to, so it reports changed every single time. Look for a real module first (there are thousands). If you must use command, add creates: or changed_when: so it stays idempotent.
Practice: two families, one playbook 🎛️
Your project is in ~/ansible. web1 runs Rocky and web2 runs Ubuntu, and your SSH key is already on both. Write the inventory, talk to the machines, then turn two blank servers into web servers with one command, twice.
Quick check
1. What do the managed nodes need installed for Ansible to work?
✓ Ansible is agentless. Only the control machine needs Ansible.
2. You run the playbook a second time and see changed=0. What does that mean?
✓ Safe to run any time, as often as you like.
3. How does one playbook install httpd on Rocky and apache2 on Ubuntu?
✓ Ansible gathers facts from each machine before running the tasks.
4. ansible web1 -m package -a 'name=tree' fails with “This command has to be run under the root user.” The fix?
✓ become = sudo on the managed node.