Linux DevOps · Lesson 4 · 45 min

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, --check and --diff

Why “as code”?

ToolBest atHow it works
AnsibleConfiguring machines that exist: packages, files, services, usersPush over SSH, YAML playbooks, no agent (Red Hat)
Terraform / OpenTofuCreating infrastructure: cloud servers, networks, DNS, databasesDeclarative HCL files, talks to cloud APIs
Puppet, Chef, SaltKeeping big fleets configured all the timeAn agent on every machine pulls its config
cloud-initFirst boot of a new cloud serverRuns once, from user data

A common combination: Terraform creates the servers, and Ansible configures them.

Install Ansible (on the control machine only)

Rocky / RHEL
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.

Ubuntu / Debian
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

inventory.ini
[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

site.yml
---
- 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?

2. You run the playbook a second time and see changed=0. What does that mean?

3. How does one playbook install httpd on Rocky and apache2 on Ubuntu?

4. ansible web1 -m package -a 'name=tree' fails with “This command has to be run under the root user.” The fix?

Finished the missions and the quiz? Mark it done to track your progress.