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
rootonly for this very first run, while the server is fresh. The playbook below creates a normal sudo user and disables root login — after that, switchansible_userto 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
| Aspect | Bash script | Ansible playbook |
|---|---|---|
| What you write | Step-by-step commands | Desired end state |
| Re-run safely? | Usually not (errors on repeat) | Yes — idempotent |
| Dry run | Hard to fake | Built in (--check --diff) |
| Readability | Drops off as it grows | Stays declarative and clear |
| When to use | One quick local task | Any server you rebuild or scale |
Best practices
- Keep secrets (passwords, API tokens) out of plain YAML — encrypt them with
ansible-vault encrypt_stringand reference the variable. - Always run
--check --diffagainst production before applying for real. - Split a growing playbook into roles (reusable bundles of tasks) so
user,firewall, andnginxlogic 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_userto 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.