Automate VPS Setup With Ansible: A Beginner's Playbook
If you've ever set up three VPS instances the same way — apt update, create a deploy user, lock down SSH, install fail2ban, repeat — you already know how easy it is to miss a step on server two or three. Ansible fixes that by turning your setup into a file you can read, review, and re-run. Here's how to get from a blank playbook to a hardened, provisioned VPS without touching most of it by hand.
Why bother with Ansible for one or two servers
If you're only ever going to manage a single VPS for the life of the box, doing it by hand is fine. Ansible starts paying off the moment any of these are true: you rebuild servers more than once a year, you run a staging box that should mirror production, you manage VPS instances for more than one client, or you just want a record of exactly what was done to a server six months after you did it.
The other advantage is less obvious: Ansible playbooks are idempotent by design. Run the same playbook five times and the server ends up in the same state every time — it doesn't try to "create a user" that already exists or reinstall a package that's already there. That means you can safely re-run your playbook after every change to catch drift, instead of wondering whether a manual step got skipped.
What you need before you start
- A control machine — your laptop or a jump box — with Python 3 installed. This is where Ansible itself runs; it does not need to be installed on the VPS.
- SSH key access to the target VPS (password auth over SSH for automation is asking for trouble later).
- A VPS running Ubuntu 22.04/24.04 or a similar Debian-based distro. The examples below use
apt; swap indnfmodule calls if you're on AlmaLinux/Rocky.
Install Ansible on the control machine:
sudo apt update
sudo apt install -y ansible
ansible --version
Step 1: Set up your inventory
Ansible needs to know which servers to talk to. Create a project folder and an inventory file:
mkdir ~/vps-provision && cd ~/vps-provision
nano inventory.ini
[vps]
203.0.113.10 ansible_user=root ansible_ssh_private_key_file=~/.ssh/id_ed25519
[vps:vars]
ansible_python_interpreter=/usr/bin/python3
Replace the IP with your actual VPS address. Test the connection before writing a single line of playbook:
ansible vps -i inventory.ini -m ping
A SUCCESS response with "pong" means Ansible can reach and authenticate to the box. If you get UNREACHABLE, check that port 22 is open and that the key path in the inventory matches a key actually loaded on your machine — ssh -i ~/.ssh/id_ed25519 root@203.0.113.10 should work on its own first.
Step 2: Write the base-hardening playbook
This is the Ansible equivalent of a first-hour checklist, except it runs the same way on every server you point it at:
---
- name: Base VPS hardening
hosts: vps
become: true
vars:
deploy_user: deploy
ssh_port: 22
tasks:
- name: Update apt cache and upgrade packages
apt:
update_cache: true
upgrade: dist
- name: Create deploy user with sudo access
user:
name: "{{ deploy_user }}"
groups: sudo
append: true
- name: Add your public key to the deploy user
authorized_key:
user: "{{ deploy_user }}"
state: present
key: "{{ lookup('file', '~/.ssh/id_ed25519.pub') }}"
- name: Disable SSH password authentication
lineinfile:
path: /etc/ssh/sshd_config
regexp: '^#?PasswordAuthentication'
line: 'PasswordAuthentication no'
notify: restart sshd
- name: Disable root SSH login
lineinfile:
path: /etc/ssh/sshd_config
regexp: '^#?PermitRootLogin'
line: 'PermitRootLogin no'
notify: restart sshd
- name: Install ufw and fail2ban
apt:
name: ['ufw', 'fail2ban']
state: present
- name: Allow SSH, HTTP, HTTPS through ufw
ufw:
rule: allow
port: "{{ item }}"
loop: ['22', '80', '443']
- name: Enable ufw
ufw:
state: enabled
policy: deny
handlers:
- name: restart sshd
service:
name: sshd
state: restarted
Save that as hardening.yml. Note the order matters: the deploy user and its key get created before password auth and root login get disabled, so you never lock yourself out mid-run.
Step 3: Run it
ansible-playbook -i inventory.ini hardening.yml
Ansible will print a task-by-task summary — ok for things already in the desired state, changed for anything it modified. Once it finishes, open a new terminal and confirm you can log in as the deploy user before closing your root session:
ssh deploy@203.0.113.10
If that works, update your inventory to use ansible_user=deploy going forward and re-run the playbook — it should report every task as ok with zero changes, which is exactly what idempotency looks like in practice.
Step 4: Layer your actual stack on top
Once the base is solid, add tasks (or a separate playbook that imports this one) for whatever you're deploying — LEMP, Docker, Node. A minimal example that installs Nginx and MySQL:
- name: Install Nginx and MySQL
apt:
name: ['nginx', 'mysql-server']
state: present
- name: Ensure Nginx is running and enabled
service:
name: nginx
state: started
enabled: true
Keep hardening and application setup in separate playbook files once your stack grows — a hardening.yml you barely touch, and a deploy.yml you edit constantly. Mixing the two makes it easy to accidentally re-run destructive app-level tasks against a server you only meant to patch.
Splitting into roles as it grows
A single flat playbook is fine for one or two servers. Past that, move each concern into its own role: roles/hardening, roles/nginx, roles/mysql. It's more folders up front, but it means you can reuse roles/hardening unchanged across every client VPS you spin up, and only the app-specific role differs between them.
Common errors and how to fix them
| Error | Cause | Fix |
|---|---|---|
UNREACHABLE! ... Permission denied (publickey) | Wrong key path in inventory, or key not yet authorized on the server | Confirm the exact key with a plain ssh -i test first, then match that path in ansible_ssh_private_key_file |
Missing sudo password | Deploy user needs a password for sudo, or NOPASSWD isn't set | Add --ask-become-pass to the run, or add the user to /etc/sudoers.d/ with NOPASSWD if this is a fully automated pipeline |
Failed to lock apt for exclusive operation | Unattended-upgrades or another apt process is running on the VPS | Add a short wait_for loop on /var/lib/dpkg/lock-frontend, or just re-run the playbook a minute later |
| Playbook "succeeds" but ufw shows inactive | The ufw enabled task ran before the allow rules, so the connection would have been cut off and Ansible skipped the actual enable | Double-check the allow rules for port 22 exist and ran before the enable task, in that exact order |
| Locked out entirely after a run | Password auth disabled before the deploy user's key was confirmed working | Use your provider's rescue console or VNC to re-enable password auth temporarily, then fix the task order and re-run |
Prevention: treat the playbook as the source of truth
The whole point breaks down if you SSH in and hand-edit something the playbook is supposed to manage — the next run either reverts your change or drifts further from what the playbook actually describes. Two habits keep this from happening:
- Commit the playbook to git. Even a single-file repo with commit history tells you what changed and when, which is worth more than it sounds like the first time a server misbehaves after a "small manual tweak."
- Re-run before you troubleshoot. If a server is behaving oddly, run the playbook again before digging into logs. If it reports changes, that's your drift — and your diagnosis.
For a Getwebup VPS, this same playbook works whether you're provisioning your first box or your fifteenth — the only thing that changes is the IP in your inventory file.
Frequently asked questions
Do I need to install Ansible on the VPS itself?
No. Ansible is agentless — you install it only on your control machine (laptop or a jump box) and it connects to the VPS over SSH. The VPS just needs Python 3, which ships by default on Ubuntu and most modern distros.
Is it safe to re-run a playbook on a server that's already configured?
Yes, that's the point. Ansible playbooks are idempotent — modules like apt, user, and ufw check current state before making changes, so a task that's already satisfied is reported as ok instead of being repeated. Re-running is the recommended way to check for configuration drift.
What happens if the playbook disables SSH password login before my key works?
You could get locked out, which is why the example playbook creates the deploy user and installs your key in earlier tasks, before the tasks that disable password authentication and root login. Always test ssh deploy@your-ip in a second terminal before closing your original root session.
Should I use Ansible for a single VPS, or is it overkill?
For a one-off box you'll never rebuild, doing it by hand is fine. Ansible starts paying off once you rebuild servers more than once a year, run staging alongside production, or manage more than one client's VPS — at that point having the setup in a reviewable file saves more time than it costs to write.
Can I use this same approach on a cPanel VPS?
Yes for the OS-level layer — user creation, SSH hardening, firewall rules, and package installs all work the same way. Just don't let Ansible manage anything inside WHM/cPanel itself (accounts, packages); use WHM's own API or the cPanel API tokens for that layer instead.