Automate VPS Configuration with Ansible: Playbooks, Roles, and Idempotent Server Setup
Manually configuring a VPS is time-consuming and error-prone. Ansible automates server configuration as code — you write YAML playbooks that describe the desired server state, and Ansible enforces it. Run the same playbook against a fresh VPS and it produces an identical configured server. Run it again and nothing changes (idempotent). This guide builds Ansible playbooks for a complete LEMP stack deployment.
Why Ansible Over Shell Scripts
- Idempotent: Running a playbook twice is safe — Ansible skips tasks already in the desired state
- Agentless: Ansible connects over SSH — no agent installed on the managed server
- Readable: YAML playbooks document what the server should look like, not just the steps to get there
- Reproducible: Spin up an identical server from scratch with one command
- Version controlled: Infrastructure configuration lives in Git alongside application code
Install Ansible (Control Machine)
# Install on your local machine (Mac, Linux, or WSL)
pip install ansible
# Verify
ansible --version
Project Structure
mkdir -p ~/ansible-vps/{inventory,roles,group_vars}
cd ~/ansible-vps
tree .
# .
# ├── inventory/
# │ └── hosts.yml # Server list
# ├── group_vars/
# │ └── all.yml # Variables for all servers
# ├── roles/
# │ ├── common/ # Base server hardening
# │ ├── nginx/ # Web server
# │ ├── php/ # PHP-FPM
# │ ├── mariadb/ # Database
# │ └── wordpress/ # WordPress deployment
# └── site.yml # Main playbook
Step 1: Inventory File
nano inventory/hosts.yml
all:
children:
webservers:
hosts:
prod-web:
ansible_host: 203.0.113.47
ansible_user: deploy
ansible_ssh_private_key_file: ~/.ssh/prod_ed25519
staging-web:
ansible_host: 203.0.113.48
ansible_user: deploy
ansible_ssh_private_key_file: ~/.ssh/dev_ed25519
databases:
hosts:
prod-db:
ansible_host: 10.0.0.5
ansible_user: deploy
# Test connectivity
ansible all -i inventory/hosts.yml -m ping
Step 2: Variables File
nano group_vars/all.yml
---
# System
ubuntu_version: "24.04"
timezone: "UTC"
# Nginx
nginx_worker_processes: auto
nginx_worker_connections: 1024
# PHP
php_version: "8.2"
php_fpm_max_children: 10
php_fpm_start_servers: 3
# MariaDB
mysql_root_password: "{{ vault_mysql_root_password }}" # From Ansible Vault
mysql_db_name: wordpress
mysql_db_user: wordpress
mysql_db_password: "{{ vault_mysql_db_password }}"
# WordPress
wp_domain: yourdomain.com
wp_admin_user: admin
wp_admin_email: admin@yourdomain.com
Step 3: Common Role — Base Server Setup
mkdir -p roles/common/{tasks,handlers}
nano roles/common/tasks/main.yml
---
- name: Update apt cache
apt:
update_cache: yes
cache_valid_time: 3600
- name: Install essential packages
apt:
name:
- curl
- wget
- git
- unzip
- ufw
- fail2ban
- htop
- vim
state: present
- name: Set timezone
timezone:
name: "{{ timezone }}"
- name: Configure UFW defaults
ufw:
default: deny
direction: incoming
- name: Allow SSH
ufw:
rule: limit
port: "22"
proto: tcp
- name: Allow HTTP and HTTPS
ufw:
rule: allow
port: "{{ item }}"
proto: tcp
loop:
- "80"
- "443"
- name: Enable UFW
ufw:
state: enabled
- name: Disable root SSH password login
lineinfile:
path: /etc/ssh/sshd_config
regexp: "^PermitRootLogin"
line: "PermitRootLogin no"
state: present
notify: reload sshd
- name: Add deploy user to sudoers
user:
name: deploy
groups: sudo
append: yes
shell: /bin/bash
nano roles/common/handlers/main.yml
---
- name: reload sshd
service:
name: ssh
state: reloaded
Step 4: Nginx Role
mkdir -p roles/nginx/{tasks,templates,handlers}
nano roles/nginx/tasks/main.yml
---
- name: Install Nginx
apt:
name: nginx
state: present
- name: Deploy Nginx configuration
template:
src: nginx.conf.j2
dest: /etc/nginx/nginx.conf
backup: yes
notify: reload nginx
- name: Deploy site configuration
template:
src: site.conf.j2
dest: "/etc/nginx/sites-available/{{ wp_domain }}"
notify: reload nginx
- name: Enable site
file:
src: "/etc/nginx/sites-available/{{ wp_domain }}"
dest: "/etc/nginx/sites-enabled/{{ wp_domain }}"
state: link
notify: reload nginx
- name: Remove default site
file:
path: /etc/nginx/sites-enabled/default
state: absent
notify: reload nginx
- name: Ensure Nginx is started and enabled
service:
name: nginx
state: started
enabled: yes
nano roles/nginx/templates/site.conf.j2
server {
listen 80;
server_name {{ wp_domain }} www.{{ wp_domain }};
root /var/www/{{ wp_domain }};
index index.php;
location / {
try_files $uri $uri/ /index.php?$args;
}
location ~ \.php$ {
fastcgi_pass unix:/var/run/php/php{{ php_version }}-fpm.sock;
include fastcgi_params;
fastcgi_param SCRIPT_FILENAME $document_root$fastcgi_script_name;
}
location ~* \.(js|css|png|jpg|jpeg|gif|ico|svg|woff2)$ {
expires max;
access_log off;
}
}
nano roles/nginx/handlers/main.yml
---
- name: reload nginx
service:
name: nginx
state: reloaded
Step 5: PHP Role
mkdir -p roles/php/tasks
nano roles/php/tasks/main.yml
---
- name: Install PHP and extensions
apt:
name:
- "php{{ php_version }}-fpm"
- "php{{ php_version }}-mysql"
- "php{{ php_version }}-curl"
- "php{{ php_version }}-gd"
- "php{{ php_version }}-mbstring"
- "php{{ php_version }}-xml"
- "php{{ php_version }}-zip"
- "php{{ php_version }}-redis"
state: present
- name: Configure PHP-FPM pool
template:
src: www.conf.j2
dest: "/etc/php/{{ php_version }}/fpm/pool.d/www.conf"
notify: restart php-fpm
- name: Enable OPcache
lineinfile:
path: "/etc/php/{{ php_version }}/fpm/conf.d/10-opcache.ini"
regexp: "^;?opcache.enable="
line: "opcache.enable=1"
notify: restart php-fpm
- name: Start and enable PHP-FPM
service:
name: "php{{ php_version }}-fpm"
state: started
enabled: yes
Step 6: Main Playbook
nano site.yml
---
- name: Configure all web servers
hosts: webservers
become: yes # Run as sudo
roles:
- common
- nginx
- php
- mariadb
- wordpress
post_tasks:
- name: Verify Nginx is running
uri:
url: "http://{{ ansible_host }}"
status_code: 200
delegate_to: localhost
Run the Playbook
# Dry run first (--check = show what would change without applying)
ansible-playbook -i inventory/hosts.yml site.yml --check
# Apply to staging first
ansible-playbook -i inventory/hosts.yml site.yml --limit staging-web
# Apply to production
ansible-playbook -i inventory/hosts.yml site.yml --limit prod-web
# Run only specific roles (tags)
ansible-playbook -i inventory/hosts.yml site.yml --tags nginx,php
Ansible Vault for Secrets
# Create encrypted variables file
ansible-vault create group_vars/vault.yml
# Add secrets (vault.yml content):
# vault_mysql_root_password: SuperSecureRootPassword!
# vault_mysql_db_password: SuperSecureDbPassword!
# Run playbook with vault password
ansible-playbook -i inventory/hosts.yml site.yml --ask-vault-pass
Getting Started
Ansible is the most practical infrastructure automation tool for VPS fleets of any size — from 1 server to 100. Start by automating your most repetitive manual task (package installation, user creation, config file deployment). Each playbook you write is configuration that documents your infrastructure and can reproduce it exactly on any new Ubuntu VPS at VPS.DO.
Conclusion
Ansible playbooks transform server configuration from a manual checklist into version-controlled, repeatable automation. A playbook that configures a LEMP stack, deploys WordPress, and hardens SSH can provision a production-ready VPS from scratch in under 10 minutes — and the same playbook applied to a running server changes only what differs from the desired state. Infrastructure as code is the right foundation for managing any VPS fleet.