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.

AnsibleVPS setupRepeatable ops
What you learn

How to turn first-day server setup into an Ansible playbook you can run again instead of repeating commands from memory.

Best for

Self-hosters managing one or a few Ubuntu VPS instances who want cleaner rebuilds and fewer manual mistakes.

Risk to watch

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.

Expected outcome: By the end, you will have a small Ansible project that creates a deploy user, installs core packages, hardens SSH, enables a firewall, and leaves the server ready for later app deployment.

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 ansible

On macOS with Homebrew:

brew install ansible

Verify it works:

ansible --version

You 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-bootstrap

Create an inventory file called inventory.ini:

[selfhost]
my-vps ansible_host=203.0.113.10 ansible_user=root

Create 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-common

Replace 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: restarted

This 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 ping

Then run a dry-style check where possible:

ansible-playbook -i inventory.ini site.yml --check --diff

Finally apply the changes:

ansible-playbook -i inventory.ini site.yml

After it finishes, open a second terminal and test login as the new user before closing your original root session:

ssh paperclip@203.0.113.10

If 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 disable

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

Warning: Never close your original root session until you have confirmed that the new user can log in and run 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.