Ansible automation managing multiple VPS servers

Ansible for VPS Automation: Getting Started with Playbooks

June 2026 · 9 min read

When you have more than 1-2 VPS servers, manually SSHing into each one to install and configure packages becomes tedious. Ansible is a configuration management tool that lets you execute "do this on all servers" instructions simultaneously via SSH, without needing to install any agent software on your VPS. You write simple, readable YAML configurations and run them idempotently — meaning you can run them repeatedly and get the same result every time.

Installing Ansible on Your Control Machine (Laptop/Jumphost)

# Ubuntu/Debian
apt install ansible -y

# macOS
brew install ansible

# pip (all OS)
pip3 install ansible

# Check version
ansible --version
Ansible runs only from your control machine: Target VPS servers don't need anything extra installed. They just need SSH and Python, which are already present on every Ubuntu/Debian installation.

Create an Inventory File

An inventory file tells Ansible which VPS servers exist and where they're located:

# Create a project folder
mkdir ~/ansible-vps && cd ~/ansible-vps

# Create inventory.ini
nano inventory.ini
[webservers]
web1 ansible_host=203.0.113.10 ansible_user=root
web2 ansible_host=203.0.113.11 ansible_user=root

[databases]
db1 ansible_host=203.0.113.20 ansible_user=root

[all:vars]
ansible_ssh_private_key_file=~/.ssh/id_ed25519
ansible_python_interpreter=/usr/bin/python3
# Test connection to all servers
ansible all -i inventory.ini -m ping

Ansible Ad-hoc Commands — Run a Single Command on All Servers

# Update packages on all servers simultaneously
ansible all -i inventory.ini -m apt -a "update_cache=yes upgrade=dist" -b

# Restart nginx on all web servers
ansible webservers -i inventory.ini -m service -a "name=nginx state=restarted" -b

# Check disk usage on all servers
ansible all -i inventory.ini -m command -a "df -h"

# Copy a file to all web servers
ansible webservers -i inventory.ini -m copy -a "src=./nginx.conf dest=/etc/nginx/nginx.conf" -b

Writing Playbooks: Set Up a New VPS in One Go

A Playbook is a YAML file that combines multiple tasks together:

# Create setup-server.yml
nano setup-server.yml
---
- name: Initial VPS Setup
  hosts: all
  become: yes   # run with sudo

  vars:
    deploy_user: deployer
    ssh_port: 22

  tasks:
    - name: Update apt cache and upgrade packages
      apt:
        update_cache: yes
        upgrade: dist
        cache_valid_time: 3600

    - name: Install essential packages
      apt:
        name:
          - curl
          - wget
          - git
          - htop
          - ufw
          - fail2ban
          - unattended-upgrades
        state: present

    - name: Create deploy user
      user:
        name: "{{ deploy_user }}"
        shell: /bin/bash
        groups: sudo
        create_home: yes

    - name: Set UFW default deny incoming
      ufw:
        default: deny
        direction: incoming

    - name: Allow SSH
      ufw:
        rule: limit
        port: "{{ ssh_port }}"
        proto: tcp

    - name: Allow HTTP and HTTPS
      ufw:
        rule: allow
        port: "{{ item }}"
      loop:
        - "80"
        - "443"

    - name: Enable UFW
      ufw:
        state: enabled

    - name: Start and enable fail2ban
      service:
        name: fail2ban
        state: started
        enabled: yes

    - name: Set timezone to Asia/Bangkok
      timezone:
        name: Asia/Bangkok
# Run the Playbook
ansible-playbook -i inventory.ini setup-server.yml

# Check with --check (dry run, no actual changes)
ansible-playbook -i inventory.ini setup-server.yml --check

Playbook: Install LEMP Stack

---
- name: Install LEMP Stack
  hosts: webservers
  become: yes

  tasks:
    - name: Install Nginx
      apt:
        name: nginx
        state: present

    - name: Install PHP-FPM 8.2
      apt:
        name:
          - php8.2-fpm
          - php8.2-mysql
          - php8.2-xml
          - php8.2-curl
          - php8.2-mbstring
          - php8.2-zip
        state: present

    - name: Install MariaDB
      apt:
        name: mariadb-server
        state: present

    - name: Start and enable services
      service:
        name: "{{ item }}"
        state: started
        enabled: yes
      loop:
        - nginx
        - php8.2-fpm
        - mariadb

    - name: Deploy Nginx config
      template:
        src: templates/nginx-site.conf.j2
        dest: /etc/nginx/sites-available/default
      notify: reload nginx

  handlers:
    - name: reload nginx
      service:
        name: nginx
        state: reloaded

Using Variables and Templates

Ansible supports Jinja2 templates, letting you customize configuration files per server:

# templates/nginx-site.conf.j2
server {
    listen 80;
    server_name {{ server_name }};
    root {{ web_root }};

    location / {
        try_files $uri $uri/ =404;
    }

    location ~ \.php$ {
        fastcgi_pass unix:/run/php/php8.2-fpm.sock;
        include fastcgi_params;
        fastcgi_param SCRIPT_FILENAME $document_root$fastcgi_script_name;
    }
}
# group_vars/webservers.yml
server_name: example.com
web_root: /var/www/html

Example: Deploy Code from a Git Repository

- name: Deploy application
  hosts: webservers
  become: yes

  tasks:
    - name: Clone/pull latest code
      git:
        repo: https://github.com/myorg/myapp.git
        dest: /var/www/myapp
        version: main
        force: yes

    - name: Install composer dependencies
      command: composer install --no-dev --optimize-autoloader
      args:
        chdir: /var/www/myapp

    - name: Run database migrations
      command: php artisan migrate --force
      args:
        chdir: /var/www/myapp

    - name: Reload PHP-FPM
      service:
        name: php8.2-fpm
        state: reloaded

Secure Password Storage with Ansible Vault

# Encrypt a file containing secrets
ansible-vault encrypt group_vars/all/secrets.yml

# Edit an encrypted file
ansible-vault edit group_vars/all/secrets.yml

# Run a Playbook with a Vault password prompt
ansible-playbook -i inventory.ini deploy.yml --ask-vault-pass
# Or store the password in a file
ansible-playbook -i inventory.ini deploy.yml --vault-password-file ~/.vault_pass

Ansible is an excellent starting point for Infrastructure as Code on VPS because it doesn't require learning a new architecture. It uses SSH which you already know, YAML that's easy to read and write, and scales from 1 server to 100+ servers using the same Playbook.

Verify Ansible is Working Correctly

# Test connection to all VPS servers
ansible all -i inventory.ini -m ping

# Correct output: SUCCESS + pong
# web1 | SUCCESS => {
#     "changed": false,
#     "ping": "pong"
# }

# Test a Playbook with dry-run before running for real
ansible-playbook -i inventory.ini setup-server.yml --check --diff

# Verify Python is present on target hosts
ansible all -i inventory.ini -m command -a "python3 --version"

# View host facts (hardware, OS, IP)
ansible web1 -i inventory.ini -m setup | grep -E "distribution|version|processor"
Tip: Run --check before every Playbook execution on production — it shows which tasks will change without making any actual changes.

Common Issues & Troubleshooting

Error: "UNREACHABLE! SSH connection failed"

Ansible can't connect via SSH, usually because SSH keys haven't been copied or known_hosts is blocking the connection.

# Copy your SSH key to the target host first
ssh-copy-id -i ~/.ssh/id_ed25519.pub [email protected]

# If known_hosts has issues (host key changed)
ssh-keygen -R 203.0.113.10

# Test SSH connection manually first
ssh -i ~/.ssh/id_ed25519 [email protected] echo "OK"

Error: "Could not find /usr/bin/python"

The target host has Python 3 but Ansible is looking for Python 2 at the old path. You need to specify the interpreter in inventory.

# Add to inventory.ini
[all:vars]
ansible_python_interpreter=/usr/bin/python3

# Or specify in the playbook
- hosts: all
  vars:
    ansible_python_interpreter: /usr/bin/python3

Error: "Missing sudo password" or become: yes task fails

The user must be able to sudo without a password, or you need to supply the password.

# Method 1: Supply sudo password when running
ansible-playbook -i inventory.ini setup.yml -b --ask-become-pass

# Method 2: Set NOPASSWD in /etc/sudoers on the target
# deployer ALL=(ALL) NOPASSWD:ALL

Playbook runs repeatedly but task shows "changed" every time — not idempotent

The command and shell modules don't know if a command ran before, so they show changed every time. Use specific modules instead.

# Wrong: shell: apt install nginx -y  (shows changed every run)
# Correct:
- name: Install nginx
  apt:
    name: nginx
    state: present   # idempotent — skips if already installed

# If you must use shell/command, add creates: or when: condition
- name: Init something
  command: /usr/bin/init-once.sh
  args:
    creates: /var/lock/init-once.lock

YAML Syntax Error — "could not find expected ':'"

YAML indentation is wrong or you used tabs instead of spaces. Check with yamllint.

# Install yamllint
pip3 install yamllint

# Check your file
yamllint setup-server.yml

# Also try ansible-lint for best practices
pip3 install ansible-lint
ansible-lint setup-server.yml

Frequently Asked Questions

What does "agentless" mean in Ansible?

It means target VPS servers don't need to install Ansible or any agent software. Ansible runs from your control machine (laptop or jumphost), connects via standard SSH, and uses Python that's already on every Linux distribution to execute tasks. The benefit is you don't have to manage or update any agent software on the servers.

What is idempotent and why does it matter?

Idempotent means running a Playbook multiple times produces the same result. Ansible modules like apt, copy, and service are designed to check the current state first — if a package is already installed, it skips reinstalling it. This makes it safe to run Playbooks repeatedly after making small config changes.

Can Ansible work with Windows Server?

Yes, but it uses WinRM instead of SSH and requires additional modules on the Ansible control node (pywinrm). Windows modules have different names like win_package, win_service, and win_copy instead of apt, service, and copy.