Home DevOps Ansible Tutorial for Beginners: Automate Your First Server in 30 Minutes
Beginner 3 min · July 11, 2026

Ansible Tutorial for Beginners: Automate Your First Server in 30 Minutes

Ansible tutorial for beginners: learn automation from scratch.

N
Naren Founder & Principal Engineer

20+ years shipping production infrastructure and CI/CD at scale. Notes here come from systems that actually shipped.

Follow
Production
production tested
July 11, 2026
last updated
1,727
articles · all by Naren
Before you start⏱ 30 min
  • Python 3.6+ on managed nodes. SSH access to target servers. A Linux/macOS control node (or WSL2 on Windows).
 ● Production Incident 🔎 Debug Guide ⚙ Triage Commands
Quick Answer

Ansible automates server setup and management using YAML playbooks. No agent needed — just SSH and Python on the target. Install it with pip install ansible, write a playbook, and run ansible-playbook playbook.yml.

✦ Definition~90s read
What is Ansible Tutorial?

Ansible is an open-source automation tool that lets you configure servers, deploy software, and orchestrate tasks without installing any agent on the target machines. You write declarative YAML playbooks, and Ansible pushes them over SSH.

Think of Ansible as a remote control for your servers.
Plain-English First

Think of Ansible as a remote control for your servers. You write a list of instructions (a playbook) like 'install nginx, copy config file, restart service'. Ansible then connects to each server via SSH and follows those instructions exactly. No need to install anything on the servers — it's like using a universal remote that works with any TV.

You've SSH'd into a server at 2am to fix a config typo. Again. You've run the same 15 commands across 10 servers, praying you don't miss one. That's not DevOps — that's manual labor. Ansible kills that pain. It's the simplest automation tool I've used in 15 years, and it's the first thing I teach every junior. By the end of this, you'll write a playbook that installs a web server, deploys a static site, and ensures it stays running — all with one command. No agents, no daemons, just SSH and YAML.

What Problem Does Ansible Solve?

Before Ansible, you had two choices: SSH into each server and type commands by hand (error-prone, slow), or use tools like Puppet/Chef that required agents installed on every node (complex, heavy). Ansible came along and said: what if we just use SSH and a simple YAML file? No agents, no daemons, no PKI infrastructure. Just your SSH keys and a playbook. That's why it won. You write a playbook once, run it against 10 or 10,000 servers, and every one ends up in the same state. It's idempotent — run it twice, nothing changes. That's the superpower.

first_playbook.ymlYAML
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
# io.thecodeforge — DevOps tutorial
---
- name: Ensure nginx is installed and running
  hosts: webservers
  become: yes
  tasks:
    - name: Install nginx
      apt:
        name: nginx
        state: present
    - name: Ensure nginx is running
      service:
        name: nginx
        state: started
        enabled: yes
Output
PLAY [Ensure nginx is installed and running] *********************************
TASK [Gathering Facts] *********************************************************
ok: [server1]
TASK [Install nginx] ***********************************************************
changed: [server1]
TASK [Ensure nginx is running] *************************************************
changed: [server1]
PLAY RECAP *********************************************************************
server1 : ok=3 changed=2 unreachable=0 failed=0 skipped=0 rescued=0 ignored=0
Senior Shortcut:
Use --check flag to dry-run a playbook: ansible-playbook playbook.yml --check. It shows what would change without actually making changes. I run this before every production deployment.

Installing Ansible and Setting Up Your First Inventory

Install Ansible with pip: pip install ansible. That's it. No server setup, no database. Now create an inventory file — a list of servers Ansible will talk to. Inventory can be INI or YAML. I prefer INI for simplicity. Here's a basic one: [webservers] web1.example.com web2.example.com. You can also use IPs. Test connectivity with ansible all -i inventory.ini -m ping. That module just checks SSH works and Python is available. If you get a pong, you're ready.

inventory.iniINI
1
2
3
4
5
6
7
# io.thecodeforge — DevOps tutorial
[webservers]
web1.example.com
web2.example.com

[dbservers]
db1.example.com ansible_user=admin ansible_ssh_private_key_file=/path/to/key
Output
web1.example.com | SUCCESS => {
"ansible_facts": {
"discovered_interpreter_python": "/usr/bin/python3"
},
"changed": false,
"ping": "pong"
}
web2.example.com | SUCCESS => {
"ansible_facts": {
"discovered_interpreter_python": "/usr/bin/python3"
},
"changed": false,
"ping": "pong"
}
Production Trap:
Never store SSH keys in the inventory file. Use ansible_ssh_private_key_file only for testing. In production, use an SSH agent or a secrets manager like HashiCorp Vault.

Writing Your First Playbook: Install and Configure Nginx

A playbook is a YAML file with one or more plays. Each play targets a group of hosts and runs tasks in order. Let's write one that installs nginx, copies a custom index.html, and ensures the service is running. The become: yes tells Ansible to use sudo. Tasks are modules — apt, copy, service. Each module is idempotent: it checks the current state and only makes changes if needed. Run it with ansible-playbook -i inventory.ini nginx.yml.

nginx.ymlYAML
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
# io.thecodeforge — DevOps tutorial
---
- name: Configure nginx web server
  hosts: webservers
  become: yes
  vars:
    http_port: 80
    server_name: example.com
  tasks:
    - name: Install nginx
      apt:
        name: nginx
        state: present
        update_cache: yes
    - name: Create website directory
      file:
        path: /var/www/{{ server_name }}
        state: directory
        owner: www-data
        group: www-data
        mode: '0755'
    - name: Copy index.html
      copy:
        src: index.html
        dest: /var/www/{{ server_name }}/index.html
        owner: www-data
        group: www-data
        mode: '0644'
    - name: Configure nginx site
      template:
        src: nginx.conf.j2
        dest: /etc/nginx/sites-available/{{ server_name }}
      notify: restart nginx
    - name: Enable site
      file:
        src: /etc/nginx/sites-available/{{ server_name }}
        dest: /etc/nginx/sites-enabled/{{ server_name }}
        state: link
      notify: restart nginx
  handlers:
    - name: restart nginx
      service:
        name: nginx
        state: restarted
Output
PLAY [Configure nginx web server] ********************************************
TASK [Gathering Facts] *********************************************************
ok: [web1.example.com]
TASK [Install nginx] ***********************************************************
changed: [web1.example.com]
TASK [Create website directory] ************************************************
changed: [web1.example.com]
TASK [Copy index.html] *********************************************************
changed: [web1.example.com]
TASK [Configure nginx site] ****************************************************
changed: [web1.example.com]
TASK [Enable site] *************************************************************
changed: [web1.example.com]
RUNNING HANDLER [restart nginx] ************************************************
changed: [web1.example.com]
PLAY RECAP *********************************************************************
web1.example.com : ok=7 changed=6 unreachable=0 failed=0 skipped=0 rescued=0 ignored=0
Interview Gold:
Handlers run only if notified and only once at the end of the play. Common mistake: notifying a handler multiple times — it still runs once. That's by design.

Using Variables and Templates for Dynamic Configs

Hardcoding values in playbooks is a rookie move. Use variables. You can define them in the playbook (under vars), in separate files, or in the inventory. Templates use Jinja2 syntax — they're files with .j2 extension that get rendered with variables. For example, an nginx config template that uses {{ server_name }} and {{ http_port }}. This lets you reuse the same playbook for different environments: dev, staging, prod — just change the variables.

nginx.conf.j2JINJA2
1
2
3
4
5
6
7
8
9
10
11
12
# io.thecodeforge — DevOps tutorial
server {
    listen {{ http_port }} default_server;
    server_name {{ server_name }};

    root /var/www/{{ server_name }};
    index index.html;

    location / {
        try_files $uri $uri/ =404;
    }
}
Output
server {
listen 80 default_server;
server_name example.com;
root /var/www/example.com;
index index.html;
location / {
try_files $uri $uri/ =404;
}
}
Senior Shortcut:
Use ansible-vault to encrypt sensitive variables like passwords or API keys. Create an encrypted file with ansible-vault create secrets.yml, then include it in your playbook with vars_files: secrets.yml. Run with --ask-vault-pass.

Ansible Modules: The Tools in Your Belt

Modules are the building blocks. Each module does one thing: apt installs packages, copy transfers files, service manages daemons, command runs arbitrary commands. There are hundreds. The most common ones for beginners: apt/yum (package management), copy/template (file management), service/systemd (service management), user (user management), file (file/directory attributes). Always prefer a specific module over command or shell — they're idempotent and give better error messages. Use command only as a last resort.

modules_demo.ymlYAML
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
# io.thecodeforge — DevOps tutorial
---
- name: Demonstrate common modules
  hosts: all
  become: yes
  tasks:
    - name: Install git (apt)
      apt:
        name: git
        state: present
    - name: Create deploy user
      user:
        name: deploy
        groups: sudo
        shell: /bin/bash
        create_home: yes
    - name: Clone a repo
      git:
        repo: 'https://github.com/example/repo.git'
        dest: /opt/app
        version: main
    - name: Set file permissions
      file:
        path: /opt/app/config.yml
        owner: deploy
        group: deploy
        mode: '0600'
    - name: Restart service
      systemd:
        name: app
        state: restarted
        daemon_reload: yes
Output
PLAY [Demonstrate common modules] ********************************************
TASK [Gathering Facts] *********************************************************
ok: [server1]
TASK [Install git (apt)] *******************************************************
changed: [server1]
TASK [Create deploy user] ******************************************************
changed: [server1]
TASK [Clone a repo] ************************************************************
changed: [server1]
TASK [Set file permissions] ****************************************************
changed: [server1]
TASK [Restart service] *********************************************************
changed: [server1]
PLAY RECAP *********************************************************************
server1 : ok=6 changed=5 unreachable=0 failed=0 skipped=0 rescued=0 ignored=0
Never Do This:
Using shell or command when a module exists. Example: shell: apt install nginx -y instead of apt: name=nginx state=present. The module is idempotent; the shell command will reinstall every time. I've seen this cause downtime when a package update changed behavior unexpectedly.

Playbook Structure: Plays, Tasks, and Handlers

A playbook is a list of plays. Each play targets a group of hosts and defines tasks. Tasks run in order. Handlers are special tasks that run only when notified by other tasks, and only once at the end of the play. Use handlers for actions like restarting a service after a config change. The notify keyword triggers a handler. Handlers are defined under handlers at the play level. They're a clean way to separate the 'do this' from 'if needed, do that'.

handlers_example.ymlYAML
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
# io.thecodeforge — DevOps tutorial
---
- name: Update app config and restart
  hosts: appservers
  become: yes
  tasks:
    - name: Update config file
      template:
        src: app.conf.j2
        dest: /etc/app/app.conf
      notify: restart app
    - name: Update another config
      copy:
        src: other.conf
        dest: /etc/app/other.conf
      notify: restart app
  handlers:
    - name: restart app
      service:
        name: app
        state: restarted
Output
PLAY [Update app config and restart] ******************************************
TASK [Gathering Facts] *********************************************************
ok: [app1]
TASK [Update config file] ******************************************************
changed: [app1]
TASK [Update another config] ***************************************************
changed: [app1]
RUNNING HANDLER [restart app] **************************************************
changed: [app1]
PLAY RECAP *********************************************************************
app1 : ok=4 changed=3 unreachable=0 failed=0 skipped=0 rescued=0 ignored=0
The Classic Bug:
Handlers run at the end of the play, not immediately. If a later task fails, handlers still run (unless you use --force-handlers). To run handlers immediately, use meta: flush_handlers.

Running Playbooks: Ad-Hoc Commands vs Playbooks

For one-off tasks, use ad-hoc commands: ansible all -i inventory.ini -m command -a 'uptime'. For repeatable automation, write playbooks. Ad-hoc is great for quick checks: ansible all -m ping, ansible all -m shell -a 'df -h'. But anything you'll run more than once belongs in a playbook. Playbooks are version-controlled, documented, and idempotent. Always use --check before running a playbook against production. Use --diff to see what changes will be made.

ad_hoc_examples.shBASH
1
2
3
4
5
6
7
8
9
10
11
12
13
# io.thecodeforge — DevOps tutorial

# Check uptime on all servers
ansible all -i inventory.ini -m command -a 'uptime'

# Install htop on all webservers
ansible webservers -i inventory.ini -m apt -a 'name=htop state=present' --become

# Dry-run a playbook
ansible-playbook -i inventory.ini nginx.yml --check

# See what would change with diff
ansible-playbook -i inventory.ini nginx.yml --check --diff
Output
web1.example.com | CHANGED | rc=0 >>
10:30:45 up 3 days, 2:15, 1 user, load average: 0.08, 0.03, 0.01
web2.example.com | CHANGED | rc=0 >>
10:30:45 up 5 days, 14:22, 2 users, load average: 0.12, 0.06, 0.02
Senior Shortcut:
Use ansible-inventory --list -i inventory.ini to see how Ansible parses your inventory. Great for debugging group membership and variable inheritance.

Idempotency: The Superpower You Must Understand

Idempotency means running the same playbook multiple times produces the same result. No unintended side effects. Ansible modules are designed to be idempotent: apt: name=nginx state=present checks if nginx is installed; if yes, it does nothing. This is why you can run a playbook daily as a cron job to enforce state. The opposite is a script that blindly runs apt install nginx — it would reinstall or error. Always write tasks that check state before acting. Use state: present or state: absent rather than state: latest unless you want upgrades.

idempotent_example.ymlYAML
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
# io.thecodeforge — DevOps tutorial
---
- name: Idempotent user management
  hosts: all
  become: yes
  tasks:
    - name: Ensure deploy user exists
      user:
        name: deploy
        state: present
        groups: sudo
    - name: Ensure old temp user is removed
      user:
        name: tempuser
        state: absent
Output
PLAY [Idempotent user management] ********************************************
TASK [Gathering Facts] *********************************************************
ok: [server1]
TASK [Ensure deploy user exists] ***********************************************
ok: [server1]
TASK [Ensure old temp user is removed] *****************************************
changed: [server1]
PLAY RECAP *********************************************************************
server1 : ok=3 changed=1 unreachable=0 failed=0 skipped=0 rescued=0 ignored=0
Production Trap:
Using command or shell breaks idempotency unless you add creates or when conditions. Example: command: /opt/install.sh creates=/opt/installed only runs if the file doesn't exist. Without that, it runs every time.
● Production incidentPOST-MORTEMseverity: high

The 4GB Container That Kept Dying

Symptom
A containerized app crashed every few hours with OOMKiller. No pattern.
Assumption
Memory leak in the app. Team spent days profiling heap dumps.
Root cause
Ansible playbook set vm.overcommit_memory=1 on the host, allowing processes to allocate more memory than available. Combined with a misconfigured memory limit in Docker, the kernel killed the container.
Fix
Set vm.overcommit_memory=0 in /etc/sysctl.conf and reload with sysctl -p. Also set memory.reservation equal to memory.limit_in_bytes in Docker.
Key lesson
  • Never tune kernel memory overcommit without understanding your workload.
  • Ansible can apply dangerous configs silently — always test in staging.
Production debug guideSystematic recovery paths for the failure modes engineers actually hit.3 entries
Symptom · 01
SSH connection timeout or 'Authentication failed'
Fix
1. Verify SSH key is added to agent: ssh-add -l. 2. Test manual SSH: ssh user@host. 3. Check ansible_user and ansible_ssh_private_key_file in inventory. 4. Increase timeout: ansible_ssh_timeout=30.
Symptom · 02
Playbook fails with 'Module not found' or 'No module named X'
Fix
1. Ensure Python 3 is installed on target: ansible all -m raw -a 'python3 --version'. 2. Set ansible_python_interpreter=/usr/bin/python3 in inventory. 3. Install missing Python packages via apt or pip.
Symptom · 03
Task reports 'ok' but nothing changed when it should
Fix
1. Run with --diff to see what Ansible thinks the state is. 2. Check if module is idempotent (e.g., command never reports 'changed' unless you use creates). 3. Verify variables are interpolated correctly with --check.
★ Ansible Tutorial for Beginners Triage Cheat SheetFirst-response commands for when things go wrong — copy-paste ready.
`Permission denied (publickey)`
Immediate action
Check SSH agent
Commands
ssh-add -l
ssh-add ~/.ssh/id_rsa
Fix now
Add key to agent and retry
`Timeout (12s) waiting for privilege escalation`+
Immediate action
Check sudoers
Commands
ansible all -m raw -a 'sudo -n true'
Fix now
Ensure become user has passwordless sudo or set ansible_become_password
`ERROR! 'apt' is not a valid attribute for a Play`+
Immediate action
Check indentation
Commands
ansible-playbook --syntax-check playbook.yml
Fix now
Fix YAML indentation: tasks must be at same level under tasks:
`Variable 'server_name' is undefined`+
Immediate action
Check variable definition
Commands
ansible-inventory --list -i inventory.ini
Fix now
Define variable in playbook vars, vars_files, or inventory group_vars
FeatureAnsiblePuppet
Agent requiredNo (SSH only)Yes
LanguageYAMLDSL (Ruby-based)
ArchitecturePush-basedPull-based
Learning curveLowMedium
IdempotencyBuilt-in modulesBuilt-in resources
⚙ Quick Reference
8 commands from this guide
FileCommand / CodePurpose
first_playbook.yml- name: Ensure nginx is installed and runningWhat Problem Does Ansible Solve?
inventory.ini[webservers]Installing Ansible and Setting Up Your First Inventory
nginx.yml- name: Configure nginx web serverWriting Your First Playbook
nginx.conf.j2server {Using Variables and Templates for Dynamic Configs
modules_demo.yml- name: Demonstrate common modulesAnsible Modules
handlers_example.yml- name: Update app config and restartPlaybook Structure
ad_hoc_examples.shansible all -i inventory.ini -m command -a 'uptime'Running Playbooks
idempotent_example.yml- name: Idempotent user managementIdempotency

Key takeaways

1
Ansible is agentless, YAML-based, and idempotent. It connects over SSH and describes desired state.
2
Install Ansible via pip in a virtual environment. Only the control node needs Ansible.
3
Use dedicated modules (apt, copy, service) instead of shell/command. They are idempotent.
4
Always run --syntax-check and --check --diff before applying any playbook.

Common mistakes to avoid

4 patterns
×

Using shell/command module for everything

Fix
Use dedicated modules (apt, copy, template, service) — they are idempotent and handle edge cases like package caching and service states.
×

Hardcoding variables in tasks

Fix
Use variables for ports, paths, and package names. Store them in defaults/main.yml or group_vars/ depending on scope.
×

Running playbooks without --syntax-check first

Fix
Always run ansible-playbook playbook.yml --syntax-check before execution. Catches YAML indentation errors and undefined variables.
×

Forgetting become for tasks that need root

Fix
Set become: yes at the play level for tasks that install packages, modify system files, or manage services.
INTERVIEW PREP · PRACTICE MODE

Interview Questions on This Topic

Q01SENIOR
How does Ansible ensure idempotency when using the `command` module?
Q02SENIOR
When would you choose Ansible over Terraform for infrastructure automati...
Q03SENIOR
What happens if a task fails in the middle of a playbook? How do you han...
Q04JUNIOR
What is the difference between `copy` and `template` modules?
Q05SENIOR
A playbook runs fine on dev but fails on prod with 'Module not found'. W...
Q06SENIOR
How would you design Ansible automation for a 1000-node cluster with dif...
Q01 of 06SENIOR

How does Ansible ensure idempotency when using the `command` module?

ANSWER
The command module is not idempotent by default. You must use the creates or removes parameter to check for a file or directory that indicates the command has already run. Alternatively, use changed_when to control when the task reports a change.
FAQ · 8 QUESTIONS

Frequently Asked Questions

01
Do I need to install Ansible on every server I want to manage?
02
What's the difference between an ad-hoc command and a playbook?
03
What does idempotent mean in Ansible?
04
What inventory format should I use?
05
How do I handle secrets and passwords in Ansible?
06
Why does my playbook say 'ok=0 changed=0'?
07
Can Ansible manage Windows servers?
08
What's the difference between ansible-core and ansible?
N
Naren Founder & Principal Engineer

20+ years shipping production infrastructure and CI/CD at scale. Notes here come from systems that actually shipped.

Follow
Verified
production tested
July 11, 2026
last updated
1,727
articles · all by Naren
🔥

That's Ansible. Mark it forged?

3 min read · try the examples if you haven't

Previous
Jenkins Build Triggers
24 / 37 · Ansible
Next
Ansible vs Terraform: When to Use Each