Your First Ansible Playbook: Automate Server Setup Without the Pain
Write your first Ansible playbook with a real-world example.
20+ years shipping production infrastructure and CI/CD at scale. Written from production experience, not tutorials.
- ✓Ansible installed and working (test with
ansible all -m ping). An inventory file with at least one target host.
An Ansible playbook is a YAML file with a list of tasks. Each task calls an Ansible module (like apt or copy). You run it with ansible-playbook playbook.yml. It connects via SSH, runs tasks in order, and reports changes.
Think of a playbook as a recipe card for your server. Instead of telling a chef each step in detail, you write 'install nginx, configure it, start it.' Ansible reads the recipe, checks what's already done, and only does what's missing. No overcooking, no burnt meals.
I've seen too many engineers SSH into 50 servers, running the same commands by hand, praying they don't fat-finger a config file at 2 AM. That's not DevOps — that's a disaster waiting to happen. Ansible playbooks automate that drudgery, and they do it without agents, without a database, and without a PhD in YAML.
The problem is simple: manual server setup is slow, error-prone, and impossible to audit. You need a repeatable, version-controlled way to configure servers. Ansible playbooks give you that. They describe the desired state of your infrastructure in plain YAML, and Ansible makes it so.
By the end of this article, you'll write a playbook that installs Nginx, configures a virtual host, deploys a static site, and ensures the service is running — all with a single command. You'll understand idempotency, modules, and the patterns that keep production playbooks clean.
What Is a Playbook? (And Why You Need One)
A playbook is a YAML file that tells Ansible what to do on your servers. It's a list of 'plays' — each play targets a group of hosts and runs a set of tasks. Tasks call modules, which are the actual tools that install packages, copy files, restart services, etc.
Without a playbook, you're SSHing into boxes and typing commands. That works for one server. For ten? You'll make mistakes. For a hundred? You'll cry. A playbook is your single source of truth — run it, and every server ends up in the exact same state.
Here's the kicker: playbooks are idempotent. Run them once, run them ten times — same result. That's because modules check the current state before making changes. If Nginx is already installed, the apt module skips it. No wasted time, no side effects.
become: yes for tasks that need root. It's cleaner than sudo in every command. And never run playbooks as root directly — use a regular user with sudo.Anatomy of a Playbook: Hosts, Tasks, and Modules
Every playbook starts with three things: a name (optional but recommended), a hosts line that tells Ansible which servers to run on, and a list of tasks. Tasks are the workhorses — each one calls a module with specific parameters.
Modules are the secret sauce. Ansible ships with hundreds — apt, yum, copy, template, service, user, file, command, and more. Each module is a self-contained unit that does one thing: install a package, copy a file, start a service. You don't write shell scripts; you declare the desired state.
For example, instead of writing apt-get install -y nginx, you write:
``yaml - name: Install Nginx apt: name: nginx state: present ``
Ansible checks if Nginx is already installed. If yes, it does nothing. If no, it installs it. That's idempotency in action.
Your First Playbook: Install and Configure Nginx
Let's write a real playbook. We'll install Nginx, create a custom web root, copy an index.html, and ensure the service is running. This is the pattern you'll use for 90% of your automation.
First, create a directory structure:
`` project/ ├── playbook.yml ├── files/ │ └── index.html └── ansible.cfg ``
The ansible.cfg file sets defaults. For a beginner, the most important setting is host_key_checking = False — otherwise Ansible will prompt you to accept SSH host keys on first connection.
Now the playbook. We'll use the apt module for package installation, file for directory creation, copy for files, and service for service management.
update_cache: yes on the apt module means Ansible uses the local package cache. If the cache is stale, you might get 'package not found'. Always include it on the first apt task.Running Your Playbook: The Command That Matters
You've written your playbook. Now run it. The command is simple:
``bash ansible-playbook -i inventory.ini setup-nginx.yml ``
But wait — you need an inventory file. The inventory tells Ansible which servers to connect to. For a single server, create inventory.ini:
``ini [webservers] web1.example.com ansible_user=deploy ``
Replace web1.example.com with your server's IP or hostname, and deploy with your SSH user. Make sure you can SSH into that server without a password (use SSH keys).
Now run the playbook. You'll see output like the example above. Green ok means no change, yellow changed means Ansible made a change. Red failed means something broke — check the error message.
Pro tip: Always run with --check first to see what would change without actually doing it:
``bash ansible-playbook -i inventory.ini setup-nginx.yml --check ``
ansible-playbook --syntax-check playbook.yml to validate YAML before running. Catches indentation errors fast.Idempotency: The Superpower You Didn't Know You Needed
Idempotency means running the same playbook multiple times produces the same result. No side effects, no duplicate work. Ansible achieves this because modules check the current state before making changes.
For example, the apt module checks if a package is already installed. If it is, it skips the install. The service module checks if a service is running. If it is, it skips the start. The copy module checks if the file content matches. If it does, it skips the copy.
This is huge. You can run a playbook daily as a cron job to enforce state. If someone manually changes a config file, the next run will fix it. No more drift.
But idempotency only works if you use the right modules. The command and shell modules are NOT idempotent — they run every time. Use them only when no module exists, and add creates or changed_when to control behavior.
shell or command for package installation or service management. Use the dedicated modules (apt, service, etc.) — they handle idempotency, error handling, and cross-platform differences.Variables and Templates: Making Playbooks Dynamic
Hardcoding values in playbooks is a rookie move. Use variables to make your playbooks reusable across environments. Define variables in the playbook itself, in separate files, or in the inventory.
For example, you might have different web roots for staging and production. Define a variable web_root and reference it with {{ web_root }}.
Templates take this further. Ansible uses Jinja2 templating to generate config files dynamically. You create a template file with .j2 extension, and the template module fills in variables.
Here's a real pattern: deploy an Nginx virtual host config using a template. The template contains placeholders like {{ server_name }} and {{ web_root }}. Ansible fills them in based on variables you define.
meta: flush_handlers or restart directly in the task.Handlers: The Elegant Way to Restart Services
Handlers are special tasks that run only when notified by another task. They're perfect for restarting services after a config change. You define handlers in the handlers section of a play, and tasks notify them using the notify keyword.
In the previous example, the template task notifies restart nginx. But here's the magic: if the template task doesn't change anything (because the file is already up-to-date), the handler is NOT called. No unnecessary restarts.
This is a huge win in production. You don't want to restart Nginx every time you run the playbook — only when the config actually changes. Handlers give you that automatically.
reload instead of restart for services that support it (like Nginx). Reload doesn't drop connections — it gracefully applies config changes. Define both handlers and choose based on the change.Debugging Playbooks: What to Do When Things Go Wrong
Playbooks fail. It's a fact of life. The most common failures: SSH connection issues, incorrect module parameters, missing files, and permission errors.
First, increase verbosity with -v, -vv, or -vvv. The more v's, the more output. -vvv shows SSH commands and module arguments — invaluable for debugging.
Second, use the debug module to print variable values:
``yaml - name: Print web_root debug: var: web_root ``
Third, use --step to run tasks one by one, confirming each before execution. This lets you see exactly where it fails.
Fourth, check the error message. Ansible errors are usually clear: "file not found", "permission denied", "module not found". Read them carefully.
Finally, use ansible ad-hoc commands to test modules directly:
``bash ansible webservers -m ping ansible webservers -m setup ``
The setup module gathers facts about the server — useful for debugging variables.
register with a module that doesn't return the expected output. Always check the module documentation for the return values. Use debug to inspect the registered variable.Organizing Playbooks: Roles and Directory Structure
As your automation grows, a single playbook file becomes unwieldy. Enter roles. A role is a self-contained unit of automation — it has its own tasks, handlers, templates, files, and variables. Roles let you reuse and share code.
Ansible expects a specific directory structure for roles:
`` roles/ └── nginx/ ├── tasks/ │ └── main.yml ├── handlers/ │ └── main.yml ├── templates/ │ └── nginx.conf.j2 ├── files/ │ └── index.html ├── vars/ │ └── main.yml └── defaults/ └── main.yml ``
You then reference the role in your playbook:
``yaml - name: Apply nginx role hosts: webservers become: yes roles: - nginx ``
Roles are the standard way to organize production playbooks. They make your code modular, testable, and shareable via Ansible Galaxy.
ansible-galaxy init role_name to scaffold a new role directory structure. It creates all the subdirectories and main.yml files for you.When Not to Use Ansible Playbooks
Ansible is great for configuration management and application deployment. But it's not a silver bullet. Here's when you should reach for something else:
- Orchestration of complex, multi-step workflows: If you need to coordinate a series of actions across multiple services with rollback logic, consider a dedicated workflow engine like Airflow or a CI/CD pipeline.
- Real-time, event-driven automation: Ansible is pull-based (you run it). For event-driven automation (e.g., auto-scaling), use tools like Terraform with cloud provider APIs or Kubernetes operators.
- Immutable infrastructure: If you're all-in on containers and immutable servers, you might not need Ansible at all. Use Dockerfiles and Kubernetes manifests instead.
- Windows environments: Ansible works with Windows via WinRM, but it's not as mature as Linux support. For Windows-heavy shops, consider PowerShell DSC.
My rule of thumb: if you're SSHing into a server to change a config file, use Ansible. If you're provisioning cloud resources, use Terraform. If you're deploying containers, use Kubernetes.
The Playbook That Deleted Logs
shell module task with rm -rf /var/log/app/* — no creates or when condition. The glob expanded to nothing on one server, and rm -rf with no arguments does nothing, but a typo in the path caused deletion of the entire log directory.shell with the file module using state=absent on specific files. Add check_mode: yes before running. Use --check flag.- Never use
shellorcommandfor destructive operations if a module exists. - Always run playbooks with
--checkfirst.
ping host or ssh user@host manually. 2. Check firewall rules (port 22 open). 3. Ensure SSH key is added to agent: ssh-add -l. 4. In ansible.cfg, set timeout = 30.become: yes is set on the play or task. 2. Verify the remote user has sudo privileges. 3. Test with ansible host -m shell -a 'whoami' -b.community.general for some modules). 3. Run ansible-doc -l | grep module_name to verify.ansible-playbook --syntax-check playbook.ymlpython3 -c "import yaml; yaml.safe_load(open('playbook.yml'))"| File | Command / Code | Purpose |
|---|---|---|
| first-playbook.yml | - name: Ensure Nginx is installed and running | What Is a Playbook? (And Why You Need One) |
| playbook-anatomy.yml | - name: Configure web server | Anatomy of a Playbook |
| setup-nginx.yml | - name: Setup Nginx with custom web root | Your First Playbook |
| run-playbook.sh | ansible-playbook -i inventory.ini setup-nginx.yml --check | Running Your Playbook |
| idempotent-vs-non.yml | - name: Compare idempotent vs non-idempotent | Idempotency |
| with-template.yml | - name: Deploy Nginx config with template | Variables and Templates |
| handlers-example.yml | - name: Demonstrate handlers | Handlers |
| debug-playbook.yml | - name: Debug example | Debugging Playbooks |
| site.yml | - name: Apply common configuration to all servers | Organizing Playbooks |
Key takeaways
Common mistakes to avoid
3 patternsHandlers that never run
notify: must exactly match the handler's name: — including capitalization. Verify with --list-tasks.YAML indentation errors
--syntax-check before every execution.Not using --check before first run
ansible-playbook playbook.yml --check --diff before the first real execution to verify what will change.Interview Questions on This Topic
How does Ansible ensure idempotency when using the `command` module?
command module is not idempotent by default — it runs every time. To make it idempotent, use the creates parameter to specify a file that, if exists, skips the command. Alternatively, use changed_when to define when the task should report 'changed'.Frequently Asked Questions
20+ years shipping production infrastructure and CI/CD at scale. Written from production experience, not tutorials.
That's Ansible. Mark it forged?
5 min read · try the examples if you haven't