DEV Community

Ashwarya
Ashwarya

Posted on

Today I Learnt: Ansible Basics — Passwordless SSH, Ad-hoc Commands, My First Playbook, and Roles

Today's topic was Ansible — one of the most common tools that comes up in DevOps interviews and real jobs. I practiced everything on 2 EC2 machines: one acts as the "control machine" (where Ansible is installed and commands are run from), and the other is the "target machine" (the one Ansible actually manages).

I'm writing this down step by step, in the same order I actually did it, along with the real errors I hit and what they meant — so future-me (and anyone else reading this before an interview) doesn't get confused by the same things.


Part 1: Setting up passwordless SSH login into the target EC2

Before Ansible can manage a remote server, it needs to be able to SSH into it without asking for a password every single time. Here's how I set that up between my 2 EC2 machines.

First, connecting to EC2 at all (the normal way)

There are two common ways people connect to an EC2 instance:

  1. Browser / AWS Console: Open the EC2 instance in the AWS console → click Connect → choose "EC2 Instance Connect" → it opens a terminal right in your browser. No extra software needed, good for quick checks.
  2. MobaXterm (on Windows): A terminal app that lets you SSH in using the .pem key file you downloaded when you created the EC2 instance. You create a new SSH session, enter the public IP, username (like ec2-user or ubuntu), and point it to your .pem file as the private key — then it logs you in, same as a terminal would.

Either way, from a terminal, connecting with a .pem file looks like this:

ssh -i /path/to/mykey.pem ec2-user@<target-ip>
Enter fullscreen mode Exit fullscreen mode

And to leave the session, you simply type:

exit
Enter fullscreen mode Exit fullscreen mode

Why passwordless SSH matters for Ansible

Ansible works by SSH-ing into target machines and running commands. If it has to ask for a password (or a .pem file path) every time, automation breaks. So the fix is: generate our own SSH key pair on the control machine, and tell the target machine to trust it permanently.

Steps I followed

Step 1 — Generate a key pair on the control machine (not the target):

ssh-keygen -t rsa -b 4096
Enter fullscreen mode Exit fullscreen mode

This asks a few questions (where to save the key, a passphrase) — I just pressed Enter to accept the defaults. This creates two files inside ~/.ssh/:

  • id_rsa → the private key (never shared, stays on the control machine)
  • id_rsa.pub → the public key (safe to share, this is what goes on the target)

Step 2 — Copy the public key's content:

cat ~/.ssh/id_rsa.pub
Enter fullscreen mode Exit fullscreen mode

This just prints the public key text on screen so I can copy it.

Step 3 — Log into the target machine using the .pem file (the old way, one last time):

ssh -i mykey.pem ec2-user@<target-ip>
Enter fullscreen mode Exit fullscreen mode

Step 4 — On the target machine, add the control machine's public key to its trusted list:

vim ~/.ssh/authorized_keys
Enter fullscreen mode Exit fullscreen mode

Press i to start typing, paste the public key text from Step 2 on a new line, then press Esc, type :x, and hit Enter to save and exit.

Step 5 — Exit the target machine, and test passwordless login:

exit
ssh ec2-user@<target-ip>
Enter fullscreen mode Exit fullscreen mode

Notice — no -i mykey.pem needed this time, and no password prompt. That's because the control machine's private key now matches the public key sitting in the target's authorized_keys file, so SSH logs you in automatically.

Good to know: authorized_keys on the target is basically a list of "these public keys are allowed in, no password needed." You can add more than one key here if more than one machine needs access.


Part 2: Installing Ansible and the Inventory File

Once passwordless SSH was working, next was installing Ansible on the control machine (on Ubuntu, this is usually sudo apt install ansible), and then creating an inventory file — a simple text file listing which machines Ansible should manage.

Creating the inventory file

echo "172.31.16.176 ansible_user=ec2-user" > inventory
Enter fullscreen mode Exit fullscreen mode

This creates a file named inventory with one line: the target's private IP, and which username Ansible should log in as on that machine.

If you have more servers, you simply add more lines to the same file — one IP (or hostname) per line. You don't need a separate file per server.

Running my first ad-hoc command

An "ad-hoc command" is a single, one-time instruction you give Ansible directly from the terminal — no file needed, no YAML, just one line:

ansible -i inventory all -m shell -a "touch devopsclass"
Enter fullscreen mode Exit fullscreen mode

Breaking this down in plain words:

  • -i inventory → "use this inventory file to know which servers to talk to"
  • all → "target every server listed in the inventory" (you could also target a specific group name instead of all)
  • -m shell → "use the shell module" (meaning: run this like a normal terminal command)
  • -a "touch devopsclass" → "here's the actual command to run" — in this case, create an empty file called devopsclass

When I ran it, Ansible printed a warning about which Python interpreter it found on the target (/usr/bin/python3.9). This is just Ansible being transparent about which Python it's using to run its modules on the target — it's not an error, just a heads-up.

The result CHANGED | rc=0 means: the command ran successfully (rc=0 = return code 0 = success), and it actually changed something on the target machine (a new file was created).


Part 3: An error I hit, and what it actually meant

While testing, I tried this:

ssh -i ~/.ssh/mykey.pem ec2-user@172.31.16.176
Enter fullscreen mode Exit fullscreen mode

And got this warning:

Warning: Identity file /home/ubuntu/.ssh/mykey.pem not accessible: No such file or directory.
Enter fullscreen mode Exit fullscreen mode

What this means in plain words: I told SSH to use a .pem file at a path that doesn't actually exist on this machine (wrong path, or the file was never copied there). Normally, this would block the login completely.

But here's the interesting part — the login still succeeded right after (Last login: ...). Why? Because I had already set up passwordless SSH in Part 1. So even though the specific .pem file I pointed to was missing, SSH automatically fell back to its default key (~/.ssh/id_rsa) — which was already trusted by the target — and logged in anyway.

Takeaway: this warning is not always a dead-end. If passwordless SSH is already configured, SSH can quietly succeed using your default key even if the exact file you mentioned doesn't exist. Good to recognize so you don't panic during a demo or interview scenario.


Part 4: Ad-hoc commands vs Playbooks

This came up a lot, so here's the simple difference:

Ad-hoc command Playbook
What it is One single command, typed directly A YAML file with multiple steps written out
Command used ansible ... ansible-playbook ...
Best for Quick, one-time checks or fixes Repeatable, documented, multi-step setups
Example "Just create this one file on all servers" "Install nginx, start it, and open the firewall port"

Simple way to remember it: ad-hoc is like typing a command directly into someone's terminal. A playbook is like handing them a written recipe they can follow (and re-run) anytime.

Using the shell module for multiple commands

The shell module isn't limited to one simple command — you can chain several together, just like you would directly in a terminal, using && or ;:

ansible -i inventory all -m shell -a "touch file1.txt && cat file1.txt && rm -f file1.txt"
Enter fullscreen mode Exit fullscreen mode

One useful detail I noticed: rm -rf doesn't throw an error even if the file is already gone (running it twice in a row both returned rc=0, success). That's different from a plain rm without -f, which would complain if the file doesn't exist. The -f (force) flag quietly ignores that.


Part 5: Writing my first Ansible Playbook

A playbook is a YAML file describing exactly what should happen, step by step. Here's the one I wrote, to install and start nginx:

---
- name: install and start nginx
  hosts: localhost
  connection: local
  become: yes

  tasks:
    - name: Install nginx
      apt:
        name: nginx
        state: present

    - name: Start nginx
      service:
        name: nginx
        state: started
Enter fullscreen mode Exit fullscreen mode

What each part means, in plain words:

  • name: → just a label/description, purely for readability.
  • hosts: → which machine(s) this playbook runs on. I used localhost here, meaning it runs on the control machine itself.
  • connection: local → tells Ansible "don't SSH anywhere, just run this directly on the machine you're already on." This pairs with hosts: localhost.
  • become: yes → "run this with admin (sudo) privileges," since installing software needs that.
  • tasks: → the actual list of steps, done top to bottom.
  • apt: name: nginx state: present → "make sure nginx is installed" (using the apt module, for Debian/Ubuntu systems).
  • service: name: nginx state: started → "make sure the nginx service is running."

Important note: because this playbook uses hosts: localhost + connection: local, it installs nginx on the control machine, not on a remote target. To run it against your actual target server(s) instead, you'd change hosts: localhost to match a group from your inventory file (e.g., hosts: all) and remove the connection: local line — then Ansible will SSH into the target(s) to do the work, the same way the ad-hoc commands did.

Running the playbook

ansible-playbook -i inventory firstplaybook.yml
Enter fullscreen mode Exit fullscreen mode

Output, step by step:

TASK [Gathering Facts] → ok        (Ansible first collects basic info about the machine)
TASK [Install nginx]   → changed   (nginx wasn't installed, so this step changed something)
TASK [Start nginx]     → ok        (nginx was already running, or started with no issue)

PLAY RECAP: ok=3  changed=1  unreachable=0  failed=0
Enter fullscreen mode Exit fullscreen mode

The PLAY RECAP line is the summary — always check this first. failed=0 and unreachable=0 means everything went smoothly.

Double-checking it actually worked

On the actual server, I confirmed nginx was really running with:

sudo systemctl status nginx
Enter fullscreen mode Exit fullscreen mode

Which showed Active: active (running) — confirming the playbook did its job.

Getting more detail with verbose mode

If something isn't clear from the normal output, add -v (or stack more vs for more detail: -vv, -vvv, up to -vvvv for maximum detail):

ansible-playbook -vvvv -i inventory firstplaybook.yml
Enter fullscreen mode Exit fullscreen mode

This is extremely useful for debugging — it shows exactly what Ansible is doing behind the scenes, including the full SSH connection details and raw module output.


Part 6: Scaling up — why Ansible Roles exist

A realistic scenario we discussed: using Terraform to spin up 3 EC2 instances, then using Ansible to configure 1 of them as a "master" and the other 2 as "workers" (a common pattern for setting up something like Kubernetes).

The problem: a real setup like this isn't 3 lines of YAML — it can easily be 50-60 tasks per playbook (installing packages, configuring files, setting environment variables, opening ports, and so on). Writing all of that in one giant playbook file becomes messy and hard to maintain.

This is exactly what Ansible Roles solve — they let you organize a big playbook into clean, reusable folders instead of one huge file.

Creating a role

ansible-galaxy role init kubernetes
Enter fullscreen mode Exit fullscreen mode

This instantly creates a proper folder structure for you:

kubernetes/
├── README.md
├── defaults/
├── files/
├── handlers/
├── meta/
├── tasks/
├── templates/
├── tests/
└── vars/
Enter fullscreen mode Exit fullscreen mode

What each folder is for, in plain words:

  • tasks/ → the actual list of steps to run (this is the heart of the role — like the tasks: section in a playbook, but organized separately).
  • defaults/ → default values for variables, which can be easily overridden later. Lowest priority variables.
  • vars/ → other variables used by the role, usually meant to stay fixed.
  • files/ → any static files you want to copy over to the target machine as-is.
  • templates/ → files that have placeholders inside them, which Ansible fills in with real values before copying (for config files that change based on variables).
  • handlers/ → actions that only run when triggered by a task — most commonly "restart this service" after a config file changes.
  • meta/ → information about the role itself (author, dependencies on other roles, supported platforms).
  • tests/ → sample files for testing the role on its own.

Why this matters: instead of one 60-task file that's hard to read, you can split work into roles — like a kubernetes-master role and a kubernetes-worker role — each with its own clean tasks/main.yml, and your main playbook just says "apply this role to these servers." Much easier to read, reuse, and debug.


Quick recap (for revision before interviews)

  • Ad-hoc command = one quick instruction (ansible ...). Playbook = a written, repeatable set of steps (ansible-playbook ...).
  • Inventory file = a plain text list of the servers Ansible manages; add more lines for more servers.
  • Passwordless SSH = generate a key pair with ssh-keygen, then add the public key to the target's ~/.ssh/authorized_keys so no password or .pem is needed on future logins.
  • hosts: localhost + connection: local = run the playbook on the control machine itself, not a remote target.
  • -vvvv = verbose mode, for seeing exactly what Ansible is doing when something isn't working.
  • Roles = the standard way to organize large, complex playbooks into clean, reusable folders (tasks, vars, defaults, handlers, templates, etc.), created instantly with ansible-galaxy role init <name>.

Top comments (0)