Ansible for VPS Automation: Getting Started with Playbooks
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
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"
--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.