IAnswerable

Provision Servers Repeatably with Ansible

14 Sep 2026

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