Automating machine setup with Ansible

A small Fedora VM example for turning setup steps into repeatable Ansible playbooks.

When I rebuild a machine, the annoying part is not installing packages again. It’s remembering the small fixes I made months ago: a package tweak, a service setting, a folder permission, a line added to some config file. Ansible gives me a place to write those decisions down and run them again.

I do not think of Ansible as a big enterprise tool first. For my use case, it is closer to a checklist that can execute itself. This example uses the Fedora Server VM from my libvirt post and turns a few setup steps into a playbook.

Installing Ansible

On my machine, I usually install Ansible with pip:

python3 -m pip install --user ansible

Note

The official installation guide covers distro-specific options if pip is not how you want to install it.

You can confirm your installation by running:

ansible --version

Creating Your inventory.yaml

The inventory is where I tell Ansible which machines it can touch. For this post there is only one target: the Fedora VM.

Here I’ll simulate automated configuration on the Fedora Server virtual machine from Creating a Fedora Server 36 Virtual Machine.

To start, create a file called inventory.yaml and inside it define:

vms:
  hosts:
    fedora:
      ansible_host: 192.168.122.143
      ansible_connection: ssh
      ansible_user: youruser

ansible_host is the IP of the machine and ansible_user is the user Ansible will use over SSH. If you are testing against your own machine instead of a VM, use localhost.

Before doing anything destructive, I like to confirm that Ansible can see the inventory:

[user@localhost ~]# ansible -i inventory.yaml all --list-hosts
hosts (1):
    fedora

After that, configure SSH access by copying your public key to the VM:

[user@localhost]# ssh-copy-id -i ~/.ssh/id_rsa.pub [email protected]
/usr/bin/ssh-copy-id: INFO: Source of key(s) to be installed: "/home/youruser/.ssh/id_rsa.pub"
The authenticity of host '192.168.122.143 (192.168.122.143)' can't be established.
ED25519 key fingerprint is ...
This key is not known by any other names
Are you sure you want to continue connecting (yes/no/[fingerprint])? yes
/usr/bin/ssh-copy-id: INFO: attempting to log in with the new key(s), to filter out any that are already installed
/usr/bin/ssh-copy-id: INFO: 1 key(s) remain to be installed -- if you are prompted now it is to install the new keys
[email protected]'s password:
bash: warning: setlocale: LC_ALL: cannot change locale (pt_BR.UTF-8)
/usr/bin/sh: warning: setlocale: LC_ALL: cannot change locale (pt_BR.UTF-8)
/usr/bin/sh: warning: setlocale: LC_ALL: cannot change locale (pt_BR.UTF-8)
/usr/bin/sh: warning: setlocale: LC_ALL: cannot change locale (pt_BR.UTF-8)

Number of key(s) added: 1

Now try logging into the machine, with:   "ssh '[email protected]'"
and check to make sure that only the key(s) you wanted were added.youruser

The first Ansible command I run against a new host is ping. It checks the SSH connection and confirms Python can run on the target:

[user@localhost]# ansible -i inventory.yaml vms -m ping
fedora | SUCCESS => {
    "ansible_facts": {
        "discovered_interpreter_python": "/usr/bin/python3"
    },
    "changed": false,
    "ping": "pong"
}

Creating Your Playbook

The playbook is the part worth keeping in Git. It is where the manual steps become repeatable tasks.

Create a new file called playbook.yaml with the following content:

- hosts: vms
  become: true
  tasks:
    - name: Update packages with DNF
      package:
	    name: '*'
        state: latest

    - name: Message
      ansible.builtin.debug:
        msg: Packages were updated!

Run it with --ask-become-pass because updating packages with DNF needs elevated permissions. If your target uses passwordless sudo, you can omit that flag.

[user@localhost]# ansible-playbook -i inventory.yaml --ask-become-pass playbook.yaml
BECOME password:

PLAY [vms] ***********************************************************************************************

TASK [Gathering Facts] ***********************************************************************************
ok: [fedora]

TASK [DNF Update] ****************************************************************************************
ok: [fedora]

TASK [Print message] *************************************************************************************
ok: [fedora] => {
    "msg": "Done!"
}

PLAY RECAP ***********************************************************************************************
fedora                     : ok=3    changed=0    unreachable=0    failed=0    skipped=0    rescued=0    ignored=0

The recap is the part I check first: failed=0 and unreachable=0 mean the playbook reached the VM and finished without errors.

Working with Variables

Variables are useful once the same playbook needs to run against machines that are almost the same, but not identical. I usually keep host-specific values in the inventory.

Defining Variables in the Inventory

For example, add an installation path to the inventory:

vms:
  hosts:
    fedora:
      ansible_host: 192.168.122.143
      ansible_connection: ssh
      ansible_user: youruser
      remote_install_path: /usr/bin

Then reference it in the playbook:

- hosts: vms
  become: true
  tasks:
    - name: Copy installation script
      ansible.builtin.copy:
        src: /home/user/Downloads/my-script.sh
        dest: "{{ remote_install_path }}/my-script.sh"

Defining List Type Variables

Lists are useful for repeated resources, like folders:

vms:
  hosts:
    fedora:
      ansible_host: 192.168.122.143
      ansible_connection: ssh
      ansible_user: youruser
      folders:
        - /usr/bin
        - /usr/local/bin

Then loop over the list:

- hosts: vms
  become: true
  tasks:
    - name: Create folders
      ansible.builtin.file:
        path: "{{ item }}"
        state: directory
      loop: "{{ folders }}"

Return Variables

Another pattern I use a lot is registering command output and making decisions from it. This example checks the CPU architecture before running architecture-specific tasks:

- hosts: vms
  become: true
  tasks:
    - name: Check architecture
      ansible.builtin.shell: uname -m
      register: host_arch
    
    - name: Display architecture
      ansible.builtin.debug:
        msg: "{{ host_arch.stdout }}"
      
    - name: Display error if not supported
      ansible.builtin.debug:
        msg: No support for aarch64
      when: host_arch.stdout == 'aarch64'	

One detail that bit me early on: Ansible variables have precedence rules. When a value looks “ignored”, check Understanding variable precedence before blaming the playbook.

Playbook Organization

Once a playbook grows, I prefer splitting it by topic instead of keeping one long file. For example:

- hosts: vms
  become: true
  tasks:
    - name: Configure DNF
      ansible.builtin.import_tasks: tasks/configure_dnf.yaml
    
    - name: Install basic packages
	  ansible.builtin.import_tasks: tasks/install_base_packages.yaml
      
    - name: Configure docker
      ansible.builtin.import_tasks: tasks/configure_docker.yaml

The imported file, tasks/configure_dnf.yaml, contains only the DNF-specific work:

- name: Configure DNF for better performance
  ansible.builtin.blockinfile:
    backup: yes
    path: /etc/dnf/dnf.conf
    block: |
      fastestmirror=true
      max_parallel_downloads=20

You can also split by playbook when the setup grows into larger areas:

- hosts: vms
- import_playbook: webservers.yaml
- import_playbook: databases.yml
- import_playbook: containers.yml

Modules

For this kind of machine setup, these are the modules I usually check first:

  • ansible.builtin.copy: Copy local or remote host files to some location on the remote host;
  • ansible.builtin.find: Returns a list of files using a criterion;
  • ansible.builtin.cron: Manages crons and environment variables;
  • ansible.builtin.dnf: Manages packages with the dnf package manager;
  • ansible.builtin.git: Deploys software using git checkout;
  • ansible.builtin.pause: Pauses playbook execution when a manual step is unavoidable;
  • ansible.builtin.lineinfile: Deals with lines in files, changes individual lines in files.

To see the complete list of modules that come integrated with Ansible, click here.

Where This Fits

This example is intentionally small. That is the point: start by automating one VM, one package update, one config change. Once the habit is there, the playbook becomes the place where those small fixes stop living only in your memory.

I’d keep the Ansible documentation nearby, along with Martin Fowler’s SnowflakeServer.

Comments

Back to top