You set up a server by hand: install packages, create a user, edit a config file, restart a service. Six months later you need a second server and nobody remembers the exact steps. Ansible solves this by turning the steps into a file you can read, review and run again.
This guide takes you from nothing to a working playbook that configures an Ubuntu server with Nginx. It assumes you can SSH into a Linux machine.
What Ansible does
Ansible is an agentless automation tool. It connects to your servers over SSH, runs small programs called modules, and reports what changed. Nothing needs to be installed on the servers except Python, which Ubuntu already has.
Three ideas carry most of the weight:
- Inventory: the list of machines to manage.
- Playbook: a YAML file describing the desired state of those machines.
- Idempotency: running a playbook twice gives the same result. The second run should report no changes.
Install Ansible on your control machine
The control machine is your laptop or a CI runner. Current ansible-core releases need Python 3.12 or newer on the control machine (the 2.20 line supports 3.12 to 3.14), while managed servers can run older Python versions. The simplest install is with pipx:
pipx install --include-deps ansible
ansible --versionWindows is not supported as a control machine; use WSL instead.
Write an inventory
Create a project folder and an inventory.ini file:
[web]
web1 ansible_host=203.0.113.10
[web:vars]
ansible_user=ubuntuTest the connection with the ping module, which checks SSH and Python rather than sending ICMP:
ansible -i inventory.ini web -m ansible.builtin.pingA green "ping": "pong" means Ansible can manage the server.
Your first playbook
Create site.yml:
- name: Configure web servers
hosts: web
become: true
tasks:
- name: Install Nginx
ansible.builtin.apt:
name: nginx
state: present
update_cache: true
cache_valid_time: 3600
- name: Ensure Nginx is running and enabled
ansible.builtin.service:
name: nginx
state: started
enabled: trueRun it:
ansible-playbook -i inventory.ini site.ymlbecome: true runs tasks with sudo. Each task uses a module with fully qualified names such as ansible.builtin.apt. Run the playbook again and every task should report ok instead of changed. That is idempotency in action.
Templates and handlers
Real configuration needs files. Ansible renders Jinja2 templates and can restart services only when a file actually changes, using handlers.
Create templates/site.conf.j2:
server {
listen 80;
server_name {{ domain }};
root /var/www/{{ domain }};
index index.html;
}Extend the playbook:
- name: Configure web servers
hosts: web
become: true
vars:
domain: example.com
tasks:
- name: Install Nginx
ansible.builtin.apt:
name: nginx
state: present
update_cache: true
- name: Create web root
ansible.builtin.file:
path: "/var/www/{{ domain }}"
state: directory
owner: www-data
mode: "0755"
- name: Deploy site config
ansible.builtin.template:
src: templates/site.conf.j2
dest: "/etc/nginx/sites-available/{{ domain }}"
mode: "0644"
notify: Reload Nginx
- name: Enable site
ansible.builtin.file:
src: "/etc/nginx/sites-available/{{ domain }}"
dest: "/etc/nginx/sites-enabled/{{ domain }}"
state: link
notify: Reload Nginx
handlers:
- name: Reload Nginx
ansible.builtin.service:
name: nginx
state: reloadedHandlers run once at the end of the play, and only if a task that notifies them reported a change. Change the template and Nginx reloads; run again with no changes and nothing restarts.
Check before you change
Two flags make playbooks safer to run:
ansible-playbook -i inventory.ini site.yml --check --diff--check shows what would change without changing it, and --diff prints file differences. Use both before running against production. A few modules cannot predict their result in check mode, so treat it as a strong hint rather than a guarantee.
Keep secrets out of Git with Ansible Vault
Never commit passwords or API keys in plain text. Ansible Vault encrypts files:
ansible-vault create group_vars/web/vault.ymlInside, store secrets with a vault_ prefix, for example vault_db_password: "...". Reference them from a normal variables file so it is obvious where a value comes from. Run playbooks with --ask-vault-pass, or point --vault-password-file at a file your CI injects at runtime.
Organise with roles as you grow
When a playbook passes a hundred lines, split it into roles. Create one with:
ansible-galaxy role init roles/nginxA role has standard folders such as tasks/, handlers/, templates/ and defaults/. Your playbook then becomes a short list of roles applied to host groups, and roles can be shared across projects.
Lint your playbooks
ansible-lint catches common mistakes such as missing names, non-FQCN module names and risky shell usage:
pipx install ansible-lint
ansible-lint site.ymlRun it in CI on every pull request, just like a code linter.
Common beginner mistakes
- Using `shell` for everything. Prefer modules like
apt,fileandtemplate; they are idempotent and report changes correctly. - Restarting services in tasks. Use handlers so services restart only when needed.
- Hard-coding hosts and values. Put them in the inventory and
group_vars/. - Skipping `--check --diff`. It costs seconds and catches surprises.
Key takeaways
- Ansible is agentless: it manages servers over SSH with Python modules.
- An inventory lists machines; a playbook declares their desired state.
- Good playbooks are idempotent: a second run reports no changes.
- Use templates with handlers so services reload only when config changes.
- Protect secrets with Ansible Vault and lint playbooks in CI.
Sources: Ansible community documentation, ansible-core 2.20 porting guide, Releases and maintenance.

Terraform and Ansible Together: Provision with One, Configure with the Other4 min read
Vercel Fluid Compute Explained: Concurrency, Active CPU Pricing and waitUntil4 min read
Ingress-NGINX Is Retired: Migrate to Kubernetes Gateway API with ingress2gateway5 min read