How to Provision a Self-Hosting VPS with Ansible
Build a fresh VPS the repeatable way with Ansible, from package updates and users to SSH hardening and Docker-ready foundations.
How to turn first-day server setup into an Ansible playbook you can run again instead of repeating commands from memory.
Self-hosters managing one or a few Ubuntu VPS instances who want cleaner rebuilds and fewer manual mistakes.
Changing SSH settings before key access is verified can lock you out. Automating a mistake just makes it faster.
Before you begin
- A fresh Ubuntu 24.04 or 22.04 VPS.
- Root or initial SSH access from your local machine.
- An SSH key pair already created locally.
- A local Linux, macOS, or WSL terminal for running Ansible.
Ansible is a strong fit for self-hosting because it lets you describe your server state in files instead of relying on a checklist in your head. That means your second VPS can be set up like your first one, and your recovery path after a mistake or migration gets much better.
Step 1: Decide what the playbook should own
Do not try to automate everything on day one. Start with the boring, repeatable parts that should look nearly identical on every server. Good first targets are package updates, a non-root sudo user, your SSH public key, UFW rules, Fail2ban, unattended upgrades, and Docker prerequisites. Leave app-specific choices for later guides.
This keeps the playbook simple enough to trust and review. If you cram every future idea into the first automation pass, debugging gets harder and lockout risk goes up.
Step 2: Install Ansible on your local machine
On Ubuntu or Debian locally:
sudo apt update
sudo apt install -y ansibleOn macOS with Homebrew:
brew install ansibleVerify it works:
ansible --versionYou do not need to install Ansible on the VPS. It runs from your local machine over SSH.
Step 3: Create a small Ansible project
Make a working directory on your local machine:
mkdir -p ~/projects/vps-bootstrap
cd ~/projects/vps-bootstrapCreate an inventory file called inventory.ini:
[selfhost]
my-vps ansible_host=203.0.113.10 ansible_user=rootCreate a variables file called group_vars/selfhost.yml:
deploy_user: paperclip
ssh_public_key: "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAA... your-key"
ssh_port: 22
ufw_allowed_tcp_ports:
- 22
- 80
- 443
base_packages:
- curl
- git
- ufw
- fail2ban
- unattended-upgrades
- ca-certificates
- apt-transport-https
- software-properties-commonReplace the IP, username, and public key with your own values. If you plan to move SSH to a non-default port later, do it only after key login is proven.
Step 4: Write the provisioning playbook
Create site.yml:
---
- hosts: selfhost
become: true
vars_files:
- group_vars/selfhost.yml
tasks:
- name: Update apt cache
apt:
update_cache: true
cache_valid_time: 3600
- name: Upgrade installed packages
apt:
upgrade: dist
- name: Install base packages
apt:
name: "{{ base_packages }}"
state: present
- name: Ensure deploy user exists
user:
name: "{{ deploy_user }}"
groups: sudo
shell: /bin/bash
append: true
create_home: true
- name: Authorize SSH key for deploy user
authorized_key:
user: "{{ deploy_user }}"
key: "{{ ssh_public_key }}"
- name: Disable root SSH password login
lineinfile:
path: /etc/ssh/sshd_config
regexp: '^#?PermitRootLogin'
line: 'PermitRootLogin prohibit-password'
notify: Restart ssh
- name: Disable SSH password authentication
lineinfile:
path: /etc/ssh/sshd_config
regexp: '^#?PasswordAuthentication'
line: 'PasswordAuthentication no'
notify: Restart ssh
- name: Allow configured TCP ports in UFW
ufw:
rule: allow
port: "{{ item }}"
proto: tcp
loop: "{{ ufw_allowed_tcp_ports }}"
- name: Enable UFW
ufw:
state: enabled
policy: deny
direction: incoming
- name: Enable unattended upgrades
copy:
dest: /etc/apt/apt.conf.d/20auto-upgrades
content: |
APT::Periodic::Update-Package-Lists "1";
APT::Periodic::Unattended-Upgrade "1";
- name: Ensure fail2ban is enabled
service:
name: fail2ban
state: started
enabled: true
handlers:
- name: Restart ssh
service:
name: ssh
state: restartedThis is intentionally small. It handles the essentials without hiding too much logic. For a first server, readable automation beats clever automation.
Step 5: Test access, then run the playbook
First confirm Ansible can reach the server:
ansible -i inventory.ini selfhost -m pingThen run a dry-style check where possible:
ansible-playbook -i inventory.ini site.yml --check --diffFinally apply the changes:
ansible-playbook -i inventory.ini site.ymlAfter it finishes, open a second terminal and test login as the new user before closing your original root session:
ssh paperclip@203.0.113.10If that works, validate the firewall and security posture:
ssh paperclip@203.0.113.10 'sudo ufw status verbose'
ssh paperclip@203.0.113.10 'systemctl status fail2ban --no-pager'
ssh paperclip@203.0.113.10 'sudo ss -tulpn'Rollback and recovery notes
If the playbook breaks SSH access, use your VPS provider console instead of guessing from your local machine. Restore /etc/ssh/sshd_config, make sure your public key still exists in /home/paperclip/.ssh/authorized_keys, and restart SSH from the console.
If UFW blocks the wrong port, temporarily disable it from the provider console:
sudo ufw disableThen fix your port list in Ansible and run the playbook again. The recovery pattern is simple: regain access first, then correct the automation so the same issue does not repeat.
Expected outcomes
- You can log in as a non-root sudo user with an SSH key.
- Core packages and protections are installed consistently.
- UFW allows only the ports you intended.
- Your server baseline lives in version-controlled files you can reuse for the next VPS.
Troubleshooting common issues
Ansible cannot connect.
Check the server IP, the SSH user in the inventory, and whether the host key prompt is waiting for manual confirmation.
The deploy user exists, but SSH login fails.
Confirm the public key is correct, permissions on ~/.ssh are sane, and the SSH daemon restarted cleanly.
UFW is active, but the site is unreachable.
You probably forgot to allow port 80 or 443, or the service is bound only to localhost.
The playbook changed too much.
Use --check --diff before rerunning and keep the playbook narrower until you trust it.
sudo.What to do next
Once the server baseline is repeatable, the next useful improvement is adding observability and alerting so failures surface quickly. Continue with How to Send Self-Hosting Alerts with ntfy.
