Skip to main content
Ansible Tutorial — Configure a Server in 30 Minutes

Ansible Tutorial — Configure a Server in 30 Minutes

KU
• 6 min read•32 views

Install Ansible, write your first playbook, and configure a remote server (nginx + a deploy user) without touching it manually. The basics that scale up.

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

bash.bash
# macOS
brew install ansible

# Ubuntu/Debian
sudo apt install ansible

# Or via pip
pip install ansible

Verify:

bash.bash
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:

bash.bash
mkdir ansible-tutorial && cd ansible-tutorial

Create hosts.ini:

ini.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:

bash.bash
ansible -i hosts.ini web -m ping

You should see:

code
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:

bash.bash
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:

yaml.yaml
---
- 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 the web group from the inventory.
  • become: true — run with sudo. Most config tasks need root.
  • tasks: — list of steps. Each task uses a module (the verb after the task name).
  • apt: update_cache=yes — run apt-get update. The cache_valid_time: 3600 means 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 the deploy user. 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:

bash.bash
ansible-playbook -i hosts.ini setup.yml --check

You should see something like:

code
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:

bash.bash
ansible-playbook -i hosts.ini setup.yml

After it finishes, hit your server in a browser:

bash.bash
curl http://my-server.example.com

You should see:

code
<h1>Hello from Ansible</h1>
<p>This server was configured automatically.</p>

Step 6: Re-run to see idempotency

Run the playbook again:

bash.bash
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:

yaml.yaml
        content: |
          <h1>Hello from Ansible v2</h1>
          <p>This server was reconfigured.</p>

Run again:

bash.bash
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.

You've got the basics. The next levels:

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.

Explore topics:Infrastructure
React
Share this post
KU

Kiril Urbonas

AI Engineer

560 articles
View all articles by Kiril Urbonas

You might have missed

Evergreen posts worth revisiting.