Skip to content
DevOps devops iac 6 min read

Provisioning a Server with Ansible

Throughout these docs you have done a lot of server setup by hand: creating users, locking down SSH, opening firewall ports, installing Nginx, and shipping an app. Doing it once by hand teaches you how things work. Doing it ten times by hand is slow and error-prone. This page is the capstone: we take everything and turn it into a single Ansible playbook (a YAML file that describes the desired state of a server, which Ansible then enforces). The result is reproducible code — run it against a brand-new Ubuntu 22.04/24.04 LTS box and you get the exact same hardened, ready-to-serve server every time.

Why turn manual steps into a playbook

A playbook is just a recipe written for a machine instead of a human. The big win is that it is idempotent: idempotent means you can run it again and again and the end result is the same: Ansible only changes what is not already correct. A bash script that runs adduser twice errors out; an Ansible play that creates a user simply reports “ok” the second time because the user already exists.

When to use this: any server you will rebuild, scale, or hand to a teammate. If you might ever need a second identical server, codify the first one.

When NOT to use this: a throwaway box you will delete in an hour, or a one-off exploratory experiment. The playbook is overhead that only pays off when the work is repeated.

What you need before you start

Ansible runs from your own laptop (the “control node”) and connects to the target server over SSH. You do not install anything on the target — that is Ansible’s “agentless” model.

# On your laptop (Ubuntu): install Ansible
sudo apt update
sudo apt install -y ansible
ansible --version

Output:

ansible [core 2.16.3]
  python version = 3.12.3
  jinja version = 3.1.3

You also need a fresh server you can reach as root (or a sudo user) over SSH, and your public SSH key ready to copy up.

The inventory file

An inventory lists the machines you manage. Create inventory.ini:

[web]
app1 ansible_host=203.0.113.10

[web:vars]
ansible_user=root
ansible_python_interpreter=/usr/bin/python3

Tip: connect as root only for this very first run, while the server is fresh. The playbook below creates a normal sudo user and disables root login — after that, switch ansible_user to your new user.

The playbook

Save this as provision.yml. Read the comments — every task maps to a manual step you have already done by hand.

---
- name: Provision a fresh Ubuntu server
  hosts: web
  become: true            # run tasks with sudo
  vars:
    admin_user: deploy
    admin_pubkey: "ssh-ed25519 AAAAC3NzaC1lZDI1... you@laptop"
    ssh_port: 2222
    app_domain: example.com

  tasks:
    - name: Update apt cache and upgrade packages
      apt:
        update_cache: true
        upgrade: dist
        cache_valid_time: 3600

    - name: Create the admin user with sudo rights
      user:
        name: "{{ admin_user }}"
        groups: sudo
        shell: /bin/bash
        create_home: true

    - name: Install the admin user's SSH public key
      authorized_key:
        user: "{{ admin_user }}"
        key: "{{ admin_pubkey }}"

    - name: Allow the admin user passwordless sudo
      copy:
        dest: "/etc/sudoers.d/{{ admin_user }}"
        content: "{{ admin_user }} ALL=(ALL) NOPASSWD:ALL\n"
        mode: "0440"
        validate: "visudo -cf %s"

    - name: Harden the SSH daemon
      copy:
        dest: /etc/ssh/sshd_config.d/99-hardening.conf
        content: |
          Port {{ ssh_port }}
          PermitRootLogin no
          PasswordAuthentication no
          PubkeyAuthentication yes
        mode: "0644"
      notify: Restart ssh

    - name: Install and enable the ufw firewall
      apt:
        name: ufw
        state: present

    - name: Set default ufw policies
      ufw:
        direction: "{{ item.direction }}"
        policy: "{{ item.policy }}"
      loop:
        - { direction: incoming, policy: deny }
        - { direction: outgoing, policy: allow }

    - name: Allow SSH, HTTP and HTTPS through ufw
      ufw:
        rule: allow
        port: "{{ item }}"
        proto: tcp
      loop:
        - "{{ ssh_port }}"
        - "80"
        - "443"

    - name: Enable ufw
      ufw:
        state: enabled

    - name: Install Nginx
      apt:
        name: nginx
        state: present

    - name: Deploy the site config
      copy:
        dest: "/etc/nginx/sites-available/{{ app_domain }}"
        content: |
          server {
              listen 80;
              server_name {{ app_domain }};
              root /var/www/{{ app_domain }};
              index index.html;
              location / {
                  try_files $uri $uri/ =404;
              }
          }
        mode: "0644"
      notify: Reload nginx

    - name: Enable the site
      file:
        src: "/etc/nginx/sites-available/{{ app_domain }}"
        dest: "/etc/nginx/sites-enabled/{{ app_domain }}"
        state: link
      notify: Reload nginx

    - name: Remove the default Nginx site
      file:
        path: /etc/nginx/sites-enabled/default
        state: absent
      notify: Reload nginx

    - name: Deploy the app's landing page
      copy:
        dest: "/var/www/{{ app_domain }}/index.html"
        content: "<h1>Hello from {{ app_domain }} — provisioned by Ansible</h1>\n"
        mode: "0644"

  handlers:
    - name: Restart ssh
      service:
        name: ssh
        state: restarted

    - name: Reload nginx
      service:
        name: nginx
        state: reloaded

A handler is a task that only runs when another task reports a change and “notifies” it. That is why SSH restarts only when its config actually changed — not on every run.

Running it

First do a dry run. The --check flag tells Ansible to predict changes without making them, and --diff shows the exact lines that would change.

ansible-playbook -i inventory.ini provision.yml --check --diff

When you are happy, run it for real:

ansible-playbook -i inventory.ini provision.yml

Output:

PLAY [Provision a fresh Ubuntu server] *****************************

TASK [Create the admin user with sudo rights] *********************
changed: [app1]

TASK [Harden the SSH daemon] **************************************
changed: [app1]

RUNNING HANDLER [Restart ssh] ************************************
changed: [app1]

PLAY RECAP *******************************************************
app1 : ok=15   changed=12   unreachable=0   failed=0

Run it a second time and watch changed drop toward 0 — proof the playbook is idempotent.

Verifying the result

# From your laptop, SSH in as the new user on the new port
ssh -p 2222 [email protected] "sudo ufw status verbose"

Output:

Status: active
Default: deny (incoming), allow (outgoing)
To                         Action      From
2222/tcp                   ALLOW IN    Anywhere
80/tcp                     ALLOW IN    Anywhere
443/tcp                    ALLOW IN    Anywhere

A curl http://example.com (or the server IP) now returns your landing page.

Imperative scripts vs declarative playbooks

AspectBash scriptAnsible playbook
What you writeStep-by-step commandsDesired end state
Re-run safely?Usually not (errors on repeat)Yes — idempotent
Dry runHard to fakeBuilt in (--check --diff)
ReadabilityDrops off as it growsStays declarative and clear
When to useOne quick local taskAny server you rebuild or scale

Best practices

  • Keep secrets (passwords, API tokens) out of plain YAML — encrypt them with ansible-vault encrypt_string and reference the variable.
  • Always run --check --diff against production before applying for real.
  • Split a growing playbook into roles (reusable bundles of tasks) so user, firewall, and nginx logic live in their own folders.
  • Pin a specific Ubuntu LTS (22.04 or 24.04) in your testing so behaviour matches production.
  • Commit the playbook and inventory to Git so every server change is reviewed and traceable.
  • After the first run, switch ansible_user to your new sudo user and the new SSH port, then delete root access.
  • Run the playbook regularly (or on a schedule) so the server keeps drifting back to its known-good state.
Last updated June 15, 2026
Was this helpful?