Ansible Tutorial — Configure a Server in 30 Minutes
Install Ansible, write your first playbook, and configure a remote server (nginx + a deploy user) without touching it manually. The basics that scale up.
Key takeaways
- Install Ansible, write your first playbook, and configure a remote server (nginx + a deploy user) without touching it manually.
- The basics that scale up.
On this page
Ansible Tutorial: Configure a Server in 30 Minutes#
By the end of this post you'll have written an Ansible playbook that configures a remote server — installs nginx, sets up a deploy user, and lays down a config file — all from your laptop, without SSHing in once. About 30 minutes.
You'll need: a Linux/macOS terminal, Python 3.9+, and SSH access to a remote Linux server (a $5/month VPS works perfectly).
What Ansible actually is#
Ansible is a tool for running commands on remote servers. You write a YAML file describing what you want a server to look like ("nginx installed, user deploy exists, this config file in place"), and Ansible SSHes in and makes it so.
The big idea: idempotency. Running the same playbook twice gives the same result as running it once. If nginx is already installed, Ansible notices and skips. If the config file already matches, no change. This makes Ansible safe to re-run — perfect for "always converge to this state" workflows.
No agent runs on the remote machine. Ansible just SSHes in and runs Python over SSH. As long as the target has SSH and Python (which Linux always does), Ansible can manage it.
Step 1: Install Ansible#
# macOS
brew install ansible
# Ubuntu/Debian
sudo apt install ansible
# Or via pip
pip install ansible
Verify:
ansible --version
You should see something like ansible [core 2.x.x].
Step 2: Set up the inventory#
The inventory tells Ansible what hosts to manage. Create a project directory:
mkdir ansible-tutorial && cd ansible-tutorial
Create hosts.ini:
[web]
my-server.example.com ansible_user=ubuntu
[web:vars]
ansible_python_interpreter=/usr/bin/python3
Replace my-server.example.com with your actual server hostname (or IP), and ubuntu with the SSH user you log in as. Ansible will SSH to that user.
Test connectivity:
ansible -i hosts.ini web -m ping
You should see:
my-server.example.com | SUCCESS => {
"changed": false,
"ping": "pong"
}
If that fails, fix SSH first — Ansible can't help if it can't SSH. Common issues: host not in ~/.ssh/known_hosts, wrong SSH key, wrong username.
Step 3: Run a single ad-hoc command#
Before writing playbooks, you can run one-off commands across your inventory:
ansible -i hosts.ini web -m shell -a "uptime"
You should see the server's uptime line. Ansible SSHed in, ran uptime, captured the output. This is the most basic Ansible interaction — useful for "what's the disk usage on all my servers?" type queries.
Step 4: Write your first playbook#
Create setup.yml:
---
- name: Configure web server
hosts: web
become: true
tasks:
- name: Update apt cache
apt:
update_cache: yes
cache_valid_time: 3600
- name: Install nginx
apt:
name: nginx
state: present
- name: Ensure nginx is started and enabled
systemd:
name: nginx
state: started
enabled: yes
- name: Create deploy user
user:
name: deploy
shell: /bin/bash
groups: www-data
append: yes
- name: Lay down a custom index.html
copy:
content: |
<h1>Hello from Ansible</h1>
<p>This server was configured automatically.</p>
dest: /var/www/html/index.html
owner: www-data
group: www-data
mode: '0644'
Walk through what each piece does:
hosts: web— apply this play to thewebgroup from the inventory.become: true— run withsudo. Most config tasks need root.tasks:— list of steps. Each task uses a module (the verb after the task name).apt: update_cache=yes— runapt-get update. Thecache_valid_time: 3600means skip if it ran in the last hour (idempotency in action).apt: name=nginx state=present— install nginx if not already installed.systemd: name=nginx state=started enabled=yes— make sure nginx is running and starts at boot.user: name=deploy ...— create thedeployuser. If it exists, no change.copy: content=...— write a file. If the file already exists with the same content and permissions, no change.
Each task has a clear name. Failures show the named task; debug becomes faster.
Step 5: Run the playbook#
Always run with --check first — that's a dry-run mode that shows what would change without making changes:
ansible-playbook -i hosts.ini setup.yml --check
You should see something like:
PLAY [Configure web server] ******************
TASK [Update apt cache] **********************
ok: [my-server.example.com]
TASK [Install nginx] **************************
changed: [my-server.example.com]
TASK [Ensure nginx is started] ****************
changed: [my-server.example.com]
...
PLAY RECAP ************************************
my-server.example.com : ok=6 changed=4 unreachable=0 failed=0
changed: 4 means four things would change. Read the list, confirm it's what you want.
Now run for real:
ansible-playbook -i hosts.ini setup.yml
After it finishes, hit your server in a browser:
curl http://my-server.example.com
You should see:
<h1>Hello from Ansible</h1>
<p>This server was configured automatically.</p>
Step 6: Re-run to see idempotency#
Run the playbook again:
ansible-playbook -i hosts.ini setup.yml
This time the recap should say changed=0 — nothing changed because nothing needs to. nginx is already installed, the user already exists, the file already has the right content.
That's the magic of idempotency. You can run this playbook every time you suspect drift, and it'll fix only what's wrong.
Step 7: Edit and re-run#
Change the index.html content in setup.yml:
content: |
<h1>Hello from Ansible v2</h1>
<p>This server was reconfigured.</p>
Run again:
ansible-playbook -i hosts.ini setup.yml
Recap shows changed=1 — only the file changed. curl to verify.
Common mistakes#
Forgetting become: true. Most operational tasks need root. Without become, the play tries to run as the SSH user and fails on anything privileged. Sprinkle become: true at the play level, not per-task.
Using shell: or command: for everything. Those modules aren't idempotent — they run every time, reporting "changed" even if nothing actually changed. Prefer module-specific verbs (apt, systemd, user, copy, template) which check current state.
Skipping the dry-run. --check mode catches a lot of mistakes. The few seconds it takes is worth it.
Hardcoding values in tasks. Server names, paths, ports — put these in variables so you can reuse playbooks across environments. vars: block at the play level, or per-host vars in inventory.
Long playbooks instead of roles. Once a playbook hits ~50 tasks, refactor into roles (organized directories of tasks/templates/vars). Easier to read, easier to reuse.
What to read next#
You've got the basics. The next levels:
- Infrastructure as Code with Ansible — the broader IaC discipline
- Best practices: Ansible role design for large teams — how to structure playbooks that grow
- Ansible playbook optimization — when 14-minute playbooks become 4-minute ones
Ansible's surface area is big but the daily-use surface is small: write a YAML file, run ansible-playbook, get expected state. Once you've done it once, every subsequent server feels like the first one.
Get the DevOps Troubleshooting Cheat Sheet
Subscribe and get our free one-page reference for the errors that eat an afternoon — CrashLoopBackOff, OOMKilled, Terraform state locks, and more — plus new guides as we publish them.
Feature Flags in Production — Provider Choice and Operational Reality
We use feature flags on roughly every customer-facing change. The provider tradeoff, the patterns that hold up, and the failure modes that show up only after a couple of years.
AWS Lambda — Deploy Your First Serverless Function
Write, package, and deploy a Lambda function using only the AWS CLI. Trigger it via a public URL. Understand what serverless actually means.
More from Infrastructure
Explore more articles in this category
Redis vs Memcached: Choosing a Cache in 2026
Both are fast in-memory stores, and both get picked by habit more than by requirements. Here is what actually differs and when each one is the right call.
Vault vs AWS Secrets Manager vs Doppler: Choosing a Secrets Tool
One is a full secrets platform, one is AWS-native and hands-off, and one is built for developer workflow. Picking by feature list alone misses the real tradeoff.
How DNS Works (Explained Simply)
A developer-friendly walk through DNS resolution, record types, TTL, and the caching quirks that cause real production bugs.
You might have missed
Evergreen posts worth revisiting.