Provision Servers Repeatably with Ansible
14 Sep 2026A fresh VPS is a blank slate that takes an hour of manual setup to become production-ready, and you’ll forget a step every time you do it by hand. Ansible turns that hour into a single command and a playbook you can read, review, and rerun.
The shape of a real playbook
Don’t write one giant tasks list. Break provisioning into roles — a directory per concern — so you can reuse pieces across projects and reason about them independently.
site.yml
inventory/hosts.ini
roles/
base/
docker/
firewall/
app-user/
# site.yml
- hosts: all
become: true
roles:
- base
- docker
- firewall
- app-user
What “production-ready” actually means
A fresh VPS to production-ready covers a fixed list, and it’s the same list every time — which is exactly why it belongs in a playbook instead of your memory:
# roles/base/tasks/main.yml
- name: Update apt cache and upgrade packages
apt:
update_cache: true
upgrade: dist
- name: Set timezone
timezone:
name: UTC
- name: Create non-root deploy user
user:
name: deploy
groups: sudo
shell: /bin/bash
- name: Disable root SSH login
lineinfile:
path: /etc/ssh/sshd_config
regexp: '^PermitRootLogin'
line: 'PermitRootLogin no'
notify: restart sshd
- name: Install fail2ban
apt:
name: fail2ban
state: present
# roles/firewall/tasks/main.yml
- name: Allow SSH, HTTP, HTTPS
ufw:
rule: allow
port: "{{ item }}"
loop: ["22", "80", "443"]
- name: Enable UFW
ufw:
state: enabled
policy: deny
Non-root user, no root SSH, a firewall with a default-deny policy, and fail2ban — that’s the baseline every internet-facing box needs, and it’s the baseline people skip when they’re setting up “just for now” and never come back to harden it.
Idempotency is the whole point
Run the playbook twice and the second run should report no changes, because every task describes desired state, not a sequence of commands. This is what makes it safe to rerun against a server that drifted, instead of a one-shot script you’re afraid to touch again.
ansible-playbook -i inventory/hosts.ini site.yml
# second run:
ansible-playbook -i inventory/hosts.ini site.yml
# PLAY RECAP: ok=14 changed=0 unreachable=0 failed=0
changed=0 on the second run is your test that the playbook is correctly idempotent. If a task shows changed every run, it’s not describing state — it’s running a command blindly. Fix that task before you trust it.
Secrets: never in the playbook, plain
Use ansible-vault for anything sensitive — API keys, database passwords — rather than committing them in plaintext because “the repo’s private anyway.”
ansible-vault encrypt_string 'super-secret-value' --name 'db_password'
Drop the encrypted block into your vars file. The repo stays safe to share even if it later goes public by accident.
New server to running app
ansible-playbook -i inventory/hosts.ini site.yml --limit new-server.example.com
One command, a fresh box, and twenty minutes later you have a hardened, provisioned server matching every other one you’ve built this way — not a slightly different one because you fat-fingered a step this time.
The gotcha
Ansible only pays off if the playbook is what actually built every server you’re running, not a description you wrote after the fact of servers you set up by hand. If your current fleet was provisioned manually, run the playbook against a fresh test VPS first and diff the result against a real production box before you trust it as your source of truth.