Every time you set up a new VPS — creating a non-root user, configuring SSH keys, installing UFW, setting up Nginx, MySQL, PHP — you're repeating the same steps you've done before. Maybe you follow a checklist. Maybe you follow your own blog post notes. Maybe you just remember most of it and fix the parts you forgot later. This works fine for one server, but it falls apart when you need to set up a second server identically, rebuild after a disaster, or onboard a teammate who needs to understand your configuration.
Ansible solves this by turning your server setup into code. Instead of manually running commands over SSH, you write playbooks — YAML files that declare what your server should look like. Run the playbook, and Ansible connects to your VPS via SSH and executes every step automatically. The same playbook works whether you're setting up one server or fifty, and it serves as living documentation of your exact server configuration.
MassiveGRID Ubuntu VPS includes: Ubuntu 24.04 LTS pre-installed · Proxmox HA cluster with automatic failover · Ceph 3x replicated NVMe storage · Independent CPU/RAM/storage scaling · 12 Tbps DDoS protection · 4 global datacenter locations · 100% uptime SLA · 24/7 human support rated 9.5/10
Deploy a self-managed VPS — from $1.99/mo
Need dedicated resources? — from $19.80/mo
Want fully managed hosting? — we handle everything
Test your playbook by deploying a Cloud VPS with minimal resources, running the playbook, verifying everything works, then tearing it down. The entire cycle costs pennies and gives you confidence that your automation works before you point it at a production server.
Why Automate VPS Setup
Manual server setup has four problems that Ansible eliminates:
Repeatability. Can you set up an identical server six months from now? With manual setup, you'll forget steps, versions will change, and your configuration will drift. With Ansible, you run the same playbook and get the same result.
Disaster recovery. If your server is compromised or experiences a catastrophic failure, how fast can you rebuild? With Ansible, the answer is "as long as it takes the playbook to run" — typically 10-15 minutes. Your backup strategy (see our automatic backups guide) handles data; Ansible handles configuration.
Consistency. When you manage multiple servers, manual setup guarantees they'll diverge over time. One server has a different PHP version, another has a slightly different Nginx config, a third forgot Fail2Ban. Ansible ensures every server matches your declared configuration.
Documentation as code. Your Ansible playbook is your documentation. Instead of a wiki page that's perpetually outdated, your playbook is the authoritative source of truth for what's installed and how it's configured — and you can verify it by running it.
Prerequisites
You need two things: a local machine with Ansible installed, and a target VPS.
Install Ansible on Your Local Machine
# macOS
brew install ansible
# Ubuntu/Debian (local workstation)
sudo apt update
sudo apt install ansible -y
# Using pip (any OS with Python)
pip install ansible
# Verify installation
ansible --version
Ansible runs on your local machine and connects to your VPS via SSH. It does not need to be installed on the target server — it uses SSH and Python (which Ubuntu includes by default).
Target VPS
Deploy an Ubuntu 24.04 VPS on MassiveGRID. You'll need the server's IP address and SSH access as root (for initial setup). After the first playbook run, you'll use the non-root user it creates.
Make sure you can SSH into the server before proceeding:
# Test SSH access
ssh root@YOUR_SERVER_IP
Ansible Inventory: Targeting Your VPS
Create a project directory for your Ansible configuration:
mkdir -p ~/ansible-vps && cd ~/ansible-vps
The inventory file tells Ansible which servers to manage. Create inventory.ini:
# ~/ansible-vps/inventory.ini
[webservers]
vps1 ansible_host=203.0.113.50 ansible_user=root ansible_port=22
# After initial setup (playbook creates a deploy user), switch to:
# vps1 ansible_host=203.0.113.50 ansible_user=deploy ansible_port=22
# Multiple servers example:
# vps2 ansible_host=203.0.113.51 ansible_user=deploy ansible_port=22
# vps3 ansible_host=203.0.113.52 ansible_user=deploy ansible_port=22
[webservers:vars]
ansible_python_interpreter=/usr/bin/python3
Test the connection:
# Ping all servers in inventory
ansible all -i inventory.ini -m ping
# Expected output:
# vps1 | SUCCESS => {
# "changed": false,
# "ping": "pong"
# }
Playbook 1: Initial Server Setup
This playbook automates everything from our Ubuntu VPS setup guide — creating a non-root user, configuring SSH keys, setting the hostname and timezone, and performing initial system updates.
Create 01-initial-setup.yml:
# ~/ansible-vps/01-initial-setup.yml
---
- name: Initial Ubuntu VPS Setup
hosts: webservers
become: yes
vars:
deploy_user: deploy
ssh_public_key: "{{ lookup('file', '~/.ssh/id_ed25519.pub') }}"
server_hostname: web-prod-01
server_timezone: UTC
tasks:
- name: Update apt cache and upgrade all packages
apt:
update_cache: yes
upgrade: dist
cache_valid_time: 3600
- name: Install essential packages
apt:
name:
- curl
- wget
- git
- htop
- unzip
- software-properties-common
- apt-transport-https
- ca-certificates
- gnupg
- lsb-release
state: present
- name: Set hostname
hostname:
name: "{{ server_hostname }}"
- name: Set timezone
timezone:
name: "{{ server_timezone }}"
- name: Create deploy user
user:
name: "{{ deploy_user }}"
groups: sudo
shell: /bin/bash
create_home: yes
state: present
- name: Add SSH public key for deploy user
authorized_key:
user: "{{ deploy_user }}"
key: "{{ ssh_public_key }}"
state: present
- name: Allow deploy user passwordless sudo
lineinfile:
path: /etc/sudoers.d/{{ deploy_user }}
line: "{{ deploy_user }} ALL=(ALL) NOPASSWD:ALL"
create: yes
mode: '0440'
validate: 'visudo -cf %s'
- name: Set vim as default editor
alternatives:
name: editor
path: /usr/bin/vim.basic
- name: Configure automatic security updates
apt:
name: unattended-upgrades
state: present
- name: Enable automatic security updates
copy:
dest: /etc/apt/apt.conf.d/20auto-upgrades
content: |
APT::Periodic::Update-Package-Lists "1";
APT::Periodic::Unattended-Upgrade "1";
APT::Periodic::AutocleanInterval "7";
mode: '0644'
Run the playbook:
ansible-playbook -i inventory.ini 01-initial-setup.yml
After this runs, update your inventory to use the deploy user instead of root:
# Update inventory.ini
[webservers]
vps1 ansible_host=203.0.113.50 ansible_user=deploy ansible_port=22
Playbook 2: Security Hardening
This playbook automates the security steps from our security hardening guide — SSH configuration, UFW firewall setup, and Fail2Ban installation. For advanced UFW rules, see our UFW firewall guide.
Create 02-security.yml:
# ~/ansible-vps/02-security.yml
---
- name: Security Hardening
hosts: webservers
become: yes
vars:
ssh_port: 22
allowed_ssh_ips: [] # Empty = allow from anywhere; add IPs to restrict
tasks:
- name: Configure SSH - disable root login
lineinfile:
path: /etc/ssh/sshd_config
regexp: '^#?PermitRootLogin'
line: 'PermitRootLogin no'
state: present
notify: restart ssh
- name: Configure SSH - disable password authentication
lineinfile:
path: /etc/ssh/sshd_config
regexp: '^#?PasswordAuthentication'
line: 'PasswordAuthentication no'
state: present
notify: restart ssh
- name: Configure SSH - disable empty passwords
lineinfile:
path: /etc/ssh/sshd_config
regexp: '^#?PermitEmptyPasswords'
line: 'PermitEmptyPasswords no'
state: present
notify: restart ssh
- name: Configure SSH - set max auth tries
lineinfile:
path: /etc/ssh/sshd_config
regexp: '^#?MaxAuthTries'
line: 'MaxAuthTries 3'
state: present
notify: restart ssh
- name: Configure SSH - disable X11 forwarding
lineinfile:
path: /etc/ssh/sshd_config
regexp: '^#?X11Forwarding'
line: 'X11Forwarding no'
state: present
notify: restart ssh
- name: Install UFW
apt:
name: ufw
state: present
- name: Set UFW default deny incoming
ufw:
direction: incoming
default: deny
- name: Set UFW default allow outgoing
ufw:
direction: outgoing
default: allow
- name: Allow SSH through UFW
ufw:
rule: allow
port: "{{ ssh_port }}"
proto: tcp
- name: Allow HTTP through UFW
ufw:
rule: allow
port: '80'
proto: tcp
- name: Allow HTTPS through UFW
ufw:
rule: allow
port: '443'
proto: tcp
- name: Enable UFW
ufw:
state: enabled
- name: Install Fail2Ban
apt:
name: fail2ban
state: present
- name: Configure Fail2Ban for SSH
copy:
dest: /etc/fail2ban/jail.local
content: |
[DEFAULT]
bantime = 3600
findtime = 600
maxretry = 5
banaction = ufw
[sshd]
enabled = true
port = {{ ssh_port }}
filter = sshd
logpath = /var/log/auth.log
maxretry = 3
bantime = 86400
mode: '0644'
notify: restart fail2ban
handlers:
- name: restart ssh
service:
name: ssh
state: restarted
- name: restart fail2ban
service:
name: fail2ban
state: restarted
ansible-playbook -i inventory.ini 02-security.yml
Playbook 3: LEMP Stack Installation
This playbook automates the LEMP stack setup — Nginx, MySQL, and PHP-FPM. It also applies key performance settings from our MySQL tuning guide.
Create 03-lemp.yml:
# ~/ansible-vps/03-lemp.yml
---
- name: LEMP Stack Installation
hosts: webservers
become: yes
vars:
php_version: "8.3"
mysql_root_password: "{{ vault_mysql_root_password }}"
server_name: example.com
web_root: "/var/www/{{ server_name }}/public"
tasks:
# --- Nginx ---
- name: Install Nginx
apt:
name: nginx
state: present
notify: start nginx
- name: Remove default Nginx site
file:
path: /etc/nginx/sites-enabled/default
state: absent
notify: reload nginx
- name: Create web root directory
file:
path: "{{ web_root }}"
state: directory
owner: www-data
group: www-data
mode: '0755'
- name: Create Nginx server block
copy:
dest: "/etc/nginx/sites-available/{{ server_name }}"
content: |
server {
listen 80;
server_name {{ server_name }} www.{{ server_name }};
root {{ web_root }};
index index.php index.html;
location / {
try_files $uri $uri/ /index.php?$query_string;
}
location ~ \.php$ {
include fastcgi_params;
fastcgi_pass unix:/run/php/php{{ php_version }}-fpm.sock;
fastcgi_param SCRIPT_FILENAME $realpath_root$fastcgi_script_name;
fastcgi_intercept_errors on;
}
location ~ /\.(ht|git|env) {
deny all;
}
# Static file caching
location ~* \.(css|js|jpg|jpeg|png|gif|ico|woff2|svg|webp)$ {
expires 30d;
add_header Cache-Control "public, immutable";
access_log off;
}
}
mode: '0644'
notify: reload nginx
- name: Enable Nginx server block
file:
src: "/etc/nginx/sites-available/{{ server_name }}"
dest: "/etc/nginx/sites-enabled/{{ server_name }}"
state: link
notify: reload nginx
- name: Test Nginx configuration
command: nginx -t
changed_when: false
# --- PHP-FPM ---
- name: Install PHP and common extensions
apt:
name:
- "php{{ php_version }}-fpm"
- "php{{ php_version }}-mysql"
- "php{{ php_version }}-mbstring"
- "php{{ php_version }}-xml"
- "php{{ php_version }}-curl"
- "php{{ php_version }}-zip"
- "php{{ php_version }}-gd"
- "php{{ php_version }}-intl"
- "php{{ php_version }}-bcmath"
- "php{{ php_version }}-opcache"
- "php{{ php_version }}-redis"
state: present
notify: restart php-fpm
- name: Configure PHP-FPM pool
lineinfile:
path: "/etc/php/{{ php_version }}/fpm/pool.d/www.conf"
regexp: "{{ item.regexp }}"
line: "{{ item.line }}"
loop:
- { regexp: '^pm =', line: 'pm = dynamic' }
- { regexp: '^pm.max_children', line: 'pm.max_children = 20' }
- { regexp: '^pm.start_servers', line: 'pm.start_servers = 5' }
- { regexp: '^pm.min_spare_servers', line: 'pm.min_spare_servers = 3' }
- { regexp: '^pm.max_spare_servers', line: 'pm.max_spare_servers = 10' }
notify: restart php-fpm
- name: Configure PHP settings
lineinfile:
path: "/etc/php/{{ php_version }}/fpm/php.ini"
regexp: "{{ item.regexp }}"
line: "{{ item.line }}"
loop:
- { regexp: '^;?upload_max_filesize', line: 'upload_max_filesize = 64M' }
- { regexp: '^;?post_max_size', line: 'post_max_size = 64M' }
- { regexp: '^;?memory_limit', line: 'memory_limit = 256M' }
- { regexp: '^;?max_execution_time', line: 'max_execution_time = 300' }
- { regexp: '^;?max_input_vars', line: 'max_input_vars = 5000' }
notify: restart php-fpm
# --- MySQL ---
- name: Install MySQL Server
apt:
name:
- mysql-server
- python3-pymysql
state: present
notify: start mysql
- name: Start MySQL service
service:
name: mysql
state: started
enabled: yes
- name: Set MySQL root password
mysql_user:
name: root
password: "{{ mysql_root_password }}"
host: localhost
login_unix_socket: /var/run/mysqld/mysqld.sock
state: present
- name: Create .my.cnf for root user
copy:
dest: /root/.my.cnf
content: |
[client]
user=root
password={{ mysql_root_password }}
mode: '0600'
- name: Remove anonymous MySQL users
mysql_user:
name: ''
host_all: yes
login_unix_socket: /var/run/mysqld/mysqld.sock
state: absent
- name: Remove MySQL test database
mysql_db:
name: test
login_unix_socket: /var/run/mysqld/mysqld.sock
state: absent
- name: Configure MySQL performance settings
copy:
dest: /etc/mysql/mysql.conf.d/custom.cnf
content: |
[mysqld]
# InnoDB settings
innodb_buffer_pool_size = 256M
innodb_log_file_size = 64M
innodb_flush_log_at_trx_commit = 2
innodb_flush_method = O_DIRECT
# Connection settings
max_connections = 100
wait_timeout = 300
interactive_timeout = 300
# Query cache (disabled in MySQL 8.0+, use application-level caching)
# Slow query log
slow_query_log = 1
slow_query_log_file = /var/log/mysql/mysql-slow.log
long_query_time = 2
mode: '0644'
notify: restart mysql
- name: Create test PHP info page
copy:
dest: "{{ web_root }}/info.php"
content: |
owner: www-data
group: www-data
mode: '0644'
handlers:
- name: start nginx
service:
name: nginx
state: started
enabled: yes
- name: reload nginx
service:
name: nginx
state: reloaded
- name: restart php-fpm
service:
name: "php{{ php_version }}-fpm"
state: restarted
- name: start mysql
service:
name: mysql
state: started
enabled: yes
- name: restart mysql
service:
name: mysql
state: restarted
Playbook 4: Docker Installation
This playbook automates the Docker installation steps — Docker CE, Docker Compose, and user permissions.
Create 04-docker.yml:
# ~/ansible-vps/04-docker.yml
---
- name: Docker Installation
hosts: webservers
become: yes
vars:
deploy_user: deploy
tasks:
- name: Install prerequisite packages
apt:
name:
- ca-certificates
- curl
- gnupg
- lsb-release
state: present
- name: Create keyrings directory
file:
path: /etc/apt/keyrings
state: directory
mode: '0755'
- name: Add Docker GPG key
apt_key:
url: https://download.docker.com/linux/ubuntu/gpg
keyring: /etc/apt/keyrings/docker.gpg
state: present
- name: Add Docker repository
apt_repository:
repo: >-
deb [arch=amd64 signed-by=/etc/apt/keyrings/docker.gpg]
https://download.docker.com/linux/ubuntu
{{ ansible_distribution_release }} stable
state: present
filename: docker
- name: Install Docker packages
apt:
name:
- docker-ce
- docker-ce-cli
- containerd.io
- docker-buildx-plugin
- docker-compose-plugin
state: present
update_cache: yes
- name: Start and enable Docker
service:
name: docker
state: started
enabled: yes
- name: Add deploy user to docker group
user:
name: "{{ deploy_user }}"
groups: docker
append: yes
- name: Configure Docker daemon
copy:
dest: /etc/docker/daemon.json
content: |
{
"log-driver": "json-file",
"log-opts": {
"max-size": "10m",
"max-file": "3"
},
"storage-driver": "overlay2"
}
mode: '0644'
notify: restart docker
- name: Verify Docker installation
command: docker --version
register: docker_version
changed_when: false
- name: Display Docker version
debug:
msg: "Docker installed: {{ docker_version.stdout }}"
- name: Verify Docker Compose
command: docker compose version
register: compose_version
changed_when: false
- name: Display Docker Compose version
debug:
msg: "Compose installed: {{ compose_version.stdout }}"
handlers:
- name: restart docker
service:
name: docker
state: restarted
ansible-playbook -i inventory.ini 04-docker.yml
Combining Playbooks into site.yml
Instead of running each playbook separately, create a master playbook that imports them all. This lets you provision a server from scratch with a single command.
Create site.yml:
# ~/ansible-vps/site.yml
---
# Master playbook - provisions a complete server from scratch
# Usage: ansible-playbook -i inventory.ini site.yml
- import_playbook: 01-initial-setup.yml
- import_playbook: 02-security.yml
- import_playbook: 03-lemp.yml
- import_playbook: 04-docker.yml
# Provision a complete server from zero to production-ready
ansible-playbook -i inventory.ini site.yml
# Run only specific playbooks using tags or by name
ansible-playbook -i inventory.ini 02-security.yml
# Run the full playbook in check mode (dry run — shows what would change)
ansible-playbook -i inventory.ini site.yml --check
# Run with verbose output for debugging
ansible-playbook -i inventory.ini site.yml -v
The beauty of Ansible's idempotency: you can run site.yml multiple times safely. If a package is already installed, Ansible skips it. If a configuration file already has the correct content, Ansible doesn't touch it. Only changes that are needed get applied.
Using Ansible Vault for Secrets
The LEMP playbook references vault_mysql_root_password. Never store passwords in plain text playbooks. Ansible Vault encrypts sensitive variables so they can be safely committed to version control.
# Create an encrypted variables file
ansible-vault create group_vars/webservers/vault.yml
This opens your editor. Add your secrets:
# group_vars/webservers/vault.yml (encrypted)
vault_mysql_root_password: "your-strong-password-here"
vault_db_app_password: "another-strong-password"
vault_api_key: "sk-abcdef123456"
Save and close. The file is now encrypted with AES-256. Ansible decrypts it automatically when running playbooks:
# Run playbook with vault (prompts for vault password)
ansible-playbook -i inventory.ini site.yml --ask-vault-pass
# Or use a password file (for automation)
echo "your-vault-password" > ~/.vault_pass
chmod 600 ~/.vault_pass
ansible-playbook -i inventory.ini site.yml --vault-password-file ~/.vault_pass
# Edit encrypted file later
ansible-vault edit group_vars/webservers/vault.yml
# View encrypted file contents
ansible-vault view group_vars/webservers/vault.yml
Your project structure should now look like this:
~/ansible-vps/
├── inventory.ini
├── site.yml
├── 01-initial-setup.yml
├── 02-security.yml
├── 03-lemp.yml
├── 04-docker.yml
└── group_vars/
└── webservers/
└── vault.yml # Encrypted secrets
Testing Your Playbook
Before running your playbook against a production server, test it. Deploy a minimal MassiveGRID Cloud VPS (the smallest plan works), run your playbook, verify the results, and destroy the test server.
Step 1: Deploy a Test VPS
Create a fresh Ubuntu 24.04 VPS. Note the IP address and add it to a test inventory file:
# test-inventory.ini
[webservers]
test-vps ansible_host=198.51.100.10 ansible_user=root ansible_port=22
[webservers:vars]
ansible_python_interpreter=/usr/bin/python3
Step 2: Run the Playbook
# Run with verbose output to see what's happening
ansible-playbook -i test-inventory.ini site.yml --ask-vault-pass -v
Step 3: Verify the Results
# SSH into the test server and verify
ssh deploy@198.51.100.10
# Check services are running
sudo systemctl status nginx
sudo systemctl status php8.3-fpm
sudo systemctl status mysql
sudo systemctl status docker
# Check UFW is active
sudo ufw status
# Check Fail2Ban is running
sudo fail2ban-client status
# Test Nginx serves PHP
curl http://198.51.100.10/info.php
Step 4: Test Idempotency
Run the playbook again. In a well-written playbook, the second run should show changed=0 for every task — proving it doesn't make unnecessary changes:
ansible-playbook -i test-inventory.ini site.yml --ask-vault-pass
# Expected output:
# PLAY RECAP
# test-vps : ok=45 changed=0 unreachable=0 failed=0
Step 5: Tear Down
Delete the test VPS from your MassiveGRID dashboard. The test is complete.
Maintaining and Updating Your Playbook
Your playbook is a living document. Here's how to keep it useful over time.
Version Control
Store your playbook in Git. This gives you a history of every change to your server configuration:
cd ~/ansible-vps
git init
echo ".vault_pass" >> .gitignore # Never commit the vault password
git add .
git commit -m "Initial server automation playbook"
The encrypted vault file (vault.yml) is safe to commit — it's AES-256 encrypted. Just keep the vault password itself secure and out of the repository.
Adding New Software
When you need to install something new on your servers, don't SSH in and run commands. Add a task to your playbook instead:
# Example: adding Redis to 03-lemp.yml
- name: Install Redis
apt:
name: redis-server
state: present
- name: Configure Redis to listen on localhost only
lineinfile:
path: /etc/redis/redis.conf
regexp: '^bind'
line: 'bind 127.0.0.1 ::1'
notify: restart redis
- name: Set Redis maxmemory
lineinfile:
path: /etc/redis/redis.conf
regexp: '^# maxmemory '
line: 'maxmemory 128mb'
notify: restart redis
# Add handler
- name: restart redis
service:
name: redis-server
state: restarted
Handling Ubuntu Version Upgrades
When Ubuntu releases a new LTS version, update your playbook variables (PHP version, repository URLs) and test on a fresh VPS before rolling out to production. Your playbook makes this safe — you can verify the entire stack works on the new OS version before touching production.
Useful Ansible Commands for Day-to-Day Management
# Run an ad-hoc command on all servers
ansible all -i inventory.ini -m command -a "df -h"
# Check uptime across all servers
ansible all -i inventory.ini -m command -a "uptime"
# Update all packages on all servers
ansible all -i inventory.ini -m apt -a "upgrade=dist update_cache=yes" --become
# Restart Nginx across all servers
ansible all -i inventory.ini -m service -a "name=nginx state=restarted" --become
# Copy a file to all servers
ansible all -i inventory.ini -m copy -a "src=./local-file.conf dest=/etc/nginx/conf.d/ mode=0644" --become
Prefer Managed Configuration?
Ansible is powerful for teams that want full control over their server configuration. But if you'd rather skip the automation layer entirely and have expert engineers manage your server configuration, security hardening, and ongoing maintenance, MassiveGRID's Managed Dedicated Cloud Servers include all of this as a service. You focus on your application; we handle the infrastructure.
When your Ansible playbook targets a production server, dedicated resources ensure installation steps complete at consistent speed without resource contention from neighboring tenants.
For self-managed servers, Ansible transforms your setup process from a multi-hour manual task into a single command. Start with the playbooks in this guide, customize them for your stack, test them on a throwaway VPS, and you'll have a server provisioning system that works reliably every time. Pair this with automated backups and monitoring, and you have a complete infrastructure management workflow that scales from one server to dozens.