Hello Ansible

Write infrastructure-as-code, control multiple machines easily.

In this Ansible tutorial, we create a single file in /tmp/.

In this whole tutorial, we'll create only 4 folders and 4 short files. But I'll show you how to build it step by step, so you can understand and adapt the process.

Draft: This article has commands written from memory. It has not gone trough quality assuarance and testing yet.

Background

Ansible is a configuration management tool. You write your infrastructure as code (IaC). You describe the end state, and ansible only makes changes if they are needed.

Ansible works trough SSH. Thus, the slave computers only need SSH daemon and Python installed.

This tutorial is tested with Debian 13-Trixie. You can probably adapt it to other Linuxes, like Kali or Ubuntu. We'll use ssh daemon on the same host for testing, so the master and the slave are the same computer here.

Test SSH account (without Ansible)

Ansible works trough SSH. Let's first test that we can use SSH without Ansbile.

It's advertised as "agentless", and indeed there is no slave daemon for ansible. It still needs SSH daemon and Python on slave machine. Ansible (the command) is only needed on master side. Master computer is also known as "controller" in Ansible lingo.

It's a good idea to set up passwordless SSH authentication for the account used with ansible. Check out Karvinen 2026: SSH public key - Login without password.

$ ssh localhost

remote$ exit

Install Ansible

Ansible is convenintly in Debian repositories.

$ sudo apt-get update
$ sudo apt-get install ansible micro bash-completion tree

Only ansible is really needed. Extras make it easier to work: - micro (text editor) - bash-completion (tab fills current word) - tree (show a tree of files and folders)

Test SSH with Ansible

Let's make a folder for our Ansible configuration.

$ cd
$ mkdir ansible/
$ cd ansible/

Let's write a list of hosts we'll be controlling. Create "hosts.ini", and add "localhost" there.

$ micro hosts.ini
$ cat hosts.ini

localhost

Later, we can add more hosts. One line per host. The hosts can be grouped, so we can have hosts in "web" category and five in "db".

Now we can make Ansible run a command on all hosts.

$ ansible all -a 'uptime' -i hosts.ini
...
localhost | CHANGED | rc=0 >>
 15:24:39 up 14 days, 20:36,  2 users,  load average: 0.55, 0.44, 0.47

We're just testing ansible here. If we just wanted to run command on remote machine, we could have 'ssh localhost "uptime"'.

Did you see the uptime? Great, Ansible can now use SSH to one host.

<a name="pythonversion"

Convenience: Stop whining about Python version

Why does Ansible have to whine about Python version? Oh, it could change - who could have guessed...

$ ansible all -a 'uptime' -i hosts.ini
[WARNING]: Host 'localhost' is using the discovered Python interpreter at '/usr/bin/python3.13', but future installation of another Python interpreter could cause a different interpreter to be discovered. See https://docs.ansible.com/ansible-core/2.19/reference_appendices/interpreter_discovery.html for more information.
localhost | CHANGED | rc=0 >>
 15:29:24 up 14 days, 20:41,  2 users,  load average: 0.27, 0.29, 0.39

Let's tell it to use the obvious Python command. We can add variables to host groups in inventory. This one we'll add to all.

$ micro hosts.ini
$ cat hosts.ini

localhost

[all:vars]
ansible_python_interpreter=/usr/bin/python3

So our only host "localhost" at the top, and variables for all hosts at the bottom.

Let's try again:

ansible$ ansible all -a 'uptime' -i hosts.ini
localhost | CHANGED | rc=0 >>
 15:31:18 up 14 days, 20:43,  2 users,  load average: 0.18, 0.27, 0.37

We can see that the Python version warning is gone.

Convenience: Just use my hosts.ini

Don't want to write "-i hosts.ini" for each 'ansible' and 'ansible-playbook' command?

You can add hosts.ini to ansible.cfg, so you don't need to add it to each command line.

$ micro ansible.cfg
$ cat ansible.cfg

[defaults]
inventory = hosts.ini

Now we don't need to add "-i hosts.ini" to every 'ansible' and 'ansbile-playbook' commands.

$ ansible all -a "uptime"
localhost | CHANGED | rc=0 >>
 15:22:18 up 14 days, 20:34,  2 users,  load average: 0.35, 0.37, 0.46

Site.yml - what computers get which roles

Site.yml lists wich groups of computers get which configuration (roles).

We want all computers to get the role "hello". We have not written the role yet.

$ micro site.yml
$ cat site.yml

- hosts: all
  roles:
    - hello

Let's run our new playbook, site.yml

$ ansible-playbook site.yml
[ERROR]: the role 'hello' was not found in /home/tero/code/terokarvinen-com/ansible/roles/:... 	
Origin: /home/tero/code/terokarvinen-com/ansible/site.yml:3:7

1 - hosts: all
2   roles:
3     - hello
        ^ column 7

Great, an error message! Most error messages bring us two letters: good news and bad news.

  • Good news: ansible-playbook has read our file, site.yml. It's even quoting some text from it.
  • Bad news: the role "hello" does not exist. Well, we have not written it yet.

First role - create a file

Role is one configured thing, for example nginx, apache2, postgresql...

But first, we'll create a "hello" role. It will create a file in /tmp/, on the slave computer. It's so simple it doesn't even need sudo.

Roles are in roles/ folder. There could be roles/nginx/, roles/postgresql/... But here, we'll have roles/hello/.

Each role folder will have standard subfolders. We'll just have tasks/. Often, we'll also see handlers/ for kicking daemons. The entry point, the code that get's run automatically, is in main.yml.

$ mkdir -p roles/hello/tasks/

$ micro roles/hello/tasks/main.yml
$ cat roles/hello/tasks/main.yml

- copy:
    dest: /tmp/hello-ansible
    content: "See you at TeroKarvinen.com!\n"

Let's run our playbook. Site.yml calls roles/hello/, tasks/main.yml is run automatically:

$ ansible-playbook site.yml
PLAY [all] 
TASK [Gathering Facts] 	
	ok: [localhost]

TASK [hello : copy] 
	changed: [localhost]

PLAY RECAP 
	localhost : ok=2 changed=1 unreachable=0 failed=0 skipped=0 rescued=0 ignored=0

Hey, it has "changed=1". So it claims to have changed the file on the slave.

Let's verify using a different tool.

$ ssh localhost 'cat /tmp/hello-ansible'
See you at TeroKarvinen.com!

Did you see the file on slave? Great, your first "Hello, Ansible world" has run.

Convenience: Show me what you do

Default is pretty terse:

TASK [hello : copy] 
	changed: [localhost]

A popular solution would be giving each task a name that repeats the code: "Copy our hello world text to the file /tmp/hello". To me, this would be similar to this C code: "i++; // increment the value of i by one". It would make sense to name a block of tasks in ansible, but this is not supported yet.

Luckily, we can make ansible print what it does. Add display_args_to_stdout:

$ micro ansible.cfg
$ cat ansible.cfg

[defaults]
inventory = hosts.ini
display_args_to_stdout = true

Now, let's run our playbook again:

$ ansible-playbook site.yml
...
TASK [hello : copy dest=/tmp/hello-ansible, content=See you at TeroKarvinen.com!
]
ok: [localhost]

Now we can see what it does:

- old: "hello : copy"
- new: "hello : copy dest=/tmp/hello-ansible, content=See you at TeroKarvinen.com!"

End result

This is what we have in the end

$ tree -F
./
├── ansible.cfg			# generic configuration
├── hosts.ini			# list of slave computers
├── roles/
│   └── hello/
│       └── tasks/
│           └── main.yml	# code for "hello" role"
└── site.yml			# which roles run on which slave

4 directories, 4 files

$ head -1000 ansible.cfg hosts.ini site.yml roles/hello/tasks/main.yml
==> ansible.cfg <==	# generic configuration
[defaults]
inventory = hosts.ini
display_args_to_stdout = true


==> hosts.ini <==	# list of slave computers
localhost

[all:vars]
ansible_python_interpreter=/usr/bin/python3

==> site.yml <==	# which roles run on which slave
- hosts: all
  roles:
    - hello

==> roles/hello/tasks/main.yml <==	# code for "hello" role
- copy:
    dest: /tmp/hello-ansible
    content: "See you at TeroKarvinen.com!\n"

And to run it all, we just run the playbook:

$ ansible-playbook site.yml
...
localhost : ok=2 changed=0 unreachable=0 failed=0 skipped=0 rescued=0 ignored=0

Troubleshooting Ansible

No trouble? No troubleshooting needed. Go enjoy your ansible!

No inventory

$ ansible all -a "uptime"
[WARNING]: No inventory was parsed, only implicit localhost is available
[WARNING]: provided hosts list is empty, only localhost is available. Note that the implicit localhost does not match 'all'
$ 

See: Convenience: Just use my hosts.ini

Ansible does not detect hosts inventory hosts.ini automatically. Use a command line flag for that:

$ ansible all -a "uptime" -i hosts.ini
...
localhost | CHANGED | rc=0 >>
 15:17:49 up 14 days, 20:29,  2 users,  load average: 0.25, 0.41, 0.51

You can add hosts.ini to ansible.cfg, so you don't need to add it to each command line.

$ micro ansible.cfg
$ cat ansible.cfg
[defaults]
inventory = hosts.ini

Now we don't need to add "-i hosts.ini" to every 'ansible' and 'ansbile-playbook' commands.

$ ansible all -a "uptime"
...
localhost | CHANGED | rc=0 >>
 15:22:18 up 14 days, 20:34,  2 users,  load average: 0.35, 0.37, 0.46

Discovered Python interpreter

$ ansible all -a 'uptime' -i hosts.ini
[WARNING]: Host 'localhost' is using the discovered Python interpreter at '/usr/bin/python3.13', but future installation of another Python interpreter could cause a different interpreter to be discovered. See https://docs.ansible.com/ansible-core/2.19/reference_appendices/interpreter_discovery.html for more information.

See Convenience: Stop whining about Python version.

Indent is two spaces: Tabs are usually invalid in YAML

[ERROR]: YAML parsing failed: Tabs are usually invalid in YAML.

Yes, my brain hurts, too. Why can't YAML use tabs like normal people?

You must indent with spaces. Each indent is two spaces. The error message will show where the mistake is:

[ERROR]: YAML parsing failed: Tabs are usually invalid in YAML.
Origin: ...ansible/site.yml:3:1

1 - hosts: all
2   roles:
3  - hello
  ^ column 1

Indent ignores the dash: conflicting action statements

[ERROR]: conflicting action statements: copy, dest Origin: ...ansible/roles/hello/tasks/main.yml:1:3

1 - copy: ^ column 3

$ cat roles/hello/tasks/main.yml
- copy:
  dest: /tmp/hello-ansible	# WRONG - too little indent
  content: "Blah" 			# WRONG - too little indent

We want to have "copy", which has two children, "dest" and "content".

Correct:

- copy:		# no indent, dash is first char on line
    dest: /tmp/hello-ansible	# four spaces on the left, two spaces from "c" in copy
    content: "See you at TeroKarvinen.com!\n"	# four spaces on the left, two from "c"

"But it looks like four" - I know, I know. It's two from the start of the word above, ignoring the dash.

Sudo needed

After a wait: "Task failed: Module failed: Failed to lock apt for exclusive operation: Failed to lock directory" and other error messages.

Ansible needs sudo on the slave to do administration. It does not complain when creating files on /tmp/, because anyone can create files there. But as soon as you start doing any normal sysop things, you will need sudo.

Add "become: true". That means becoming the sudo user on slave machine.

$ cat site.yml
- hosts: all
  become: true
  roles:
    - apt
    - sshd
	# ...

Now it will probably complain it does not know your sudo password.

$ ansible-playbook site.yml --ask-become-pass

Now it asks your sudo password before running the commands.

If you get tired of typing your sudo password, there are many ways around it, with different levels of security. For example, you can create an ansible specific user with passwordless sudo. Or use ansible vault. Or pass. Or even read sudo password from file, which sounds less than secure.