Introduction
I've been managing servers manually for 9 years. SSH in, run some commands, edit a config file, restart a service, pray it works. It was fine for one or two servers. But when you're managing multiple environments across development, staging, and production — it becomes a nightmare.
Enter Ansible — an agentless[1]
automation tool that lets you describe your infrastructure as code. No more manual SSH sessions.
No more forgetting that one php.ini tweak on the staging server. Everything is
reproducible, version-controlled, and idempotent.
"Infrastructure as code is not about replacing sysadmins. It's about giving them superpowers."
— Some wise person on Hacker News
Why Ansible?
There are several configuration management tools out there — Chef, Puppet, Salt — but I chose Ansible for these reasons:
- Agentless — No need to install anything on target servers. Just SSH.
- YAML-based — Playbooks are written in YAML, which is easy to read and write
- Idempotent — Run it 100 times, same result. No side effects.
- Huge community — Ansible Galaxy has thousands of pre-built roles
- Python-powered — Easy to extend with custom modules
- Write modules in Python
- Or use raw shell commands as a fallback
Comparison with Other Tools
| Feature | Ansible | Chef | Puppet | Salt |
|---|---|---|---|---|
| Language | YAML | Ruby DSL | Puppet DSL | YAML |
| Agent Required | No ✓ | Yes | Yes | Optional |
| Learning Curve | Low ✓ | High | Medium | Medium |
| Push/Pull | Push | Pull | Pull | Both |
| Best For | Multi-purpose | Complex infra | Enterprise | Event-driven |
Prerequisites
Before we start, make sure you have:
- A local machine with Python 3.8+ installed
- Ansible installed (
pip install ansible) - An AWS EC2 instance (Ubuntu 22.04) with SSH access
- Your SSH key added to the remote server
apt for yum or dnf.
Quick install check:
# Check Ansible version
ansible --version
# Should output something like:
# ansible [core 2.16.x]
# python version = 3.11.x Project Structure
Here's how I organize my Ansible project. Keeping things structured from day one saves you headaches later:
lemp-ansible/
├── inventory/
│ ├── production.ini
│ └── staging.ini
├── roles/
│ ├── common/
│ │ └── tasks/main.yml
│ ├── nginx/
│ │ ├── tasks/main.yml
│ │ ├── templates/default.conf.j2
│ │ └── handlers/main.yml
│ ├── php/
│ │ ├── tasks/main.yml
│ │ └── templates/php.ini.j2
│ └── mysql/
│ ├── tasks/main.yml
│ └── defaults/main.yml
├── site.yml
├── ansible.cfg
└── README.md - inventory/
- Contains your server lists, grouped by environment
- roles/
- Self-contained units of automation — each role handles one service
- site.yml
- The main playbook that ties everything together
- ansible.cfg
- Project-level Ansible configuration overrides
Writing the Playbook
Here's the main site.yml playbook. This is where the magic happens:
ansible-vault to encrypt
sensitive data. Run ansible-vault encrypt_string 'your_password' --name 'vault_mysql_root_pw'
to generate an encrypted value.
Now let's look at the Nginx role. This is the most interesting one because it uses Jinja2 templates:
# roles/nginx/tasks/main.yml
---
- name: Install Nginx
apt:
name: nginx
state: present
update_cache: yes
- name: Deploy Nginx config
template:
src: default.conf.j2
dest: /etc/nginx/sites-available/{{ domain_name }}
owner: root
mode: '0644'
notify: Restart Nginx
- name: Enable site
file:
src: /etc/nginx/sites-available/{{ domain_name }}
dest: /etc/nginx/sites-enabled/{{ domain_name }}
state: link
notify: Restart Nginx
- name: Start and enable Nginx
service:
name: nginx
state: started
enabled: yes And here's the Jinja2 template for the Nginx config:
{# roles/nginx/templates/default.conf.j2 #}
server {
listen 80;
server_name {{ domain_name }};
root /var/www/{{ domain_name }}/public;
index index.php index.html;
location / {
try_files $uri $uri/ /index.php?$query_string;
}
location ~ \.php$ {
fastcgi_pass unix:/run/php/php{{ php_version }}-fpm.sock;
fastcgi_param SCRIPT_FILENAME $document_root$fastcgi_script_name;
include fastcgi_params;
}
location ~ /\.ht {
deny all;
}
} Inventory Configuration
The inventory file tells Ansible which servers to target:
# inventory/production.ini
[webservers]
web1 ansible_host=52.66.xxx.xxx ansible_user=ubuntu
web2 ansible_host=13.233.xxx.xxx ansible_user=ubuntu
[webservers:vars]
ansible_ssh_private_key_file=~/.ssh/aws-prod.pem
ansible_python_interpreter=/usr/bin/python3 Running It
Time for the moment of truth. Here's how you run the playbook:
# Dry run first (check mode) — ALWAYS do this!
ansible-playbook -i inventory/production.ini site.yml --check
# If everything looks good, run for real
ansible-playbook -i inventory/production.ini site.yml
# Run only specific tags
ansible-playbook -i inventory/production.ini site.yml --tags "nginx,php"
# With vault password for encrypted vars
ansible-playbook -i inventory/production.ini site.yml --ask-vault-pass If all goes well, you'll see something beautiful:
PLAY [webservers] ****************************************************
TASK [common : Update apt cache] *************************************
ok: [web1]
ok: [web2]
TASK [nginx : Install Nginx] *****************************************
changed: [web1]
changed: [web2]
TASK [php : Install PHP 8.3] *****************************************
changed: [web1]
changed: [web2]
TASK [mysql : Install MySQL] *****************************************
changed: [web1]
changed: [web2]
PLAY RECAP ***********************************************************
web1: ok=12 changed=8 unreachable=0 failed=0
web2: ok=12 changed=8 unreachable=0 failed=0 That feeling when all tasks show ok or changed with zero failures... chef's kiss.
Nested quote: It's even better when you run it a second time and everything shows "ok" — that's idempotency at work!
Gotchas & Lessons Learned
Here's what tripped me up along the way. Learn from my mistakes:
Checklist of Things I Got Wrong
- [✓] Forgot to set
become: true— nothing installed because no sudo - [✓] Used
aptmodule withoutupdate_cache: yes— packages not found - [✓] Hardcoded passwords in the playbook (fixed with ansible-vault)
- [✓] Didn't use handlers — Nginx restarted on every run instead of only on changes
- [✗] Still need to add Let's Encrypt SSL automation
- [✗] Still need to set up automated backups with cron
--check mode first, and test on a staging server. I once accidentally
purged all Nginx configs by forgetting the state: present parameter.
The site was down for 23 minutes. Don't be like me.
Key Takeaways
- Start simple — Don't try to automate everything at once. Begin with one role.
- Use
--checkreligiously — Dry run before every real deployment - Version control your playbooks — Git is your friend. Tag your releases.
- Use roles, not monolithic playbooks — Roles are reusable and testable
- Encrypt secrets with Vault — Never commit passwords to Git
🔧 Click to see my ansible.cfg
# ansible.cfg
[defaults]
inventory = inventory/production.ini
remote_user = ubuntu
private_key_file = ~/.ssh/aws-prod.pem
host_key_checking = False
retry_files_enabled = False
stdout_callback = yaml
[privilege_escalation]
become = True
become_method = sudo Conclusion
Moving from manual server management to Ansible has been a game changer. What used to take me 2-3 hours of careful SSH work now takes 5 minutes and a single command. The best part? It's the same result every time.
If you're a backend developer who's been putting off learning DevOps — start with Ansible. It's the gentlest on-ramp into infrastructure automation, and the skills transfer directly to more complex tools like Terraform and Kubernetes later.
Hit me up in the comments or on the guestbook if you have questions. I'm happy to help fellow devs get started with this stuff.
Until next time — keep automating! Use Ctrl + C responsibly. 🚀
- Agentless means Ansible doesn't require any software to be installed on the target servers. It communicates entirely over SSH. This is unlike Chef or Puppet which require a client agent running on each managed node. ↩
ansible-lintto catch common mistakes before running playbooks. It's saved me countless times.