How to Automate VPS Server Setup with Ansible: Repeatable, Version-Controlled Infrastructure
Every time you provision a new VPS and manually run through server setup steps — creating users, hardening SSH, configuring firewall rules, installing packages — you are doing work that could be automated, documented, and version-controlled. Ansible turns your server configuration into repeatable, auditable code. Run an Ansible playbook against a fresh VPS and it reaches the desired state in minutes, identically every time. This guide builds a complete Ansible playbook for VPS server setup, covering everything from initial hardening to web server deployment.
Why Ansible for VPS Automation?
- Agentless: Ansible connects via SSH — nothing to install on target servers
- Idempotent: Running the same playbook twice produces the same result, without duplicating or breaking existing configuration
- YAML-based: Playbooks are readable by non-specialists — they serve as living documentation of your server configuration
- Version-controlled: Store playbooks in Git alongside your application code — every change is tracked, reviewable, and reversible
- Low learning curve: Compared to Chef, Puppet, or Terraform, Ansible has the gentlest onboarding for teams new to infrastructure as code
Prerequisites
- A control machine (your laptop or a CI server) with Python 3 and Ansible installed
- One or more fresh VPS instances reachable via SSH from your control machine
- SSH key on the VPS for root (initial setup) or a sudo user
# Install Ansible on your control machine (Ubuntu/Debian)
sudo apt install ansible -y
# Or via pip
pip3 install ansible --user
ansible --version
Project Structure
Organize your Ansible project with a clear directory structure:
vps-ansible/
├── inventory/
│ ├── production.ini
│ └── staging.ini
├── group_vars/
│ └── all.yml
├── roles/
│ ├── common/
│ │ ├── tasks/main.yml
│ │ ├── handlers/main.yml
│ │ └── templates/
│ ├── nginx/
│ │ ├── tasks/main.yml
│ │ └── templates/nginx.conf.j2
│ └── mariadb/
│ └── tasks/main.yml
├── site.yml
└── web.yml
Step 1: Define Your Inventory
The inventory tells Ansible which servers to manage:
nano inventory/production.ini
[webservers]
web1 ansible_host=203.0.113.10 ansible_user=root
web2 ansible_host=203.0.113.11 ansible_user=root
[dbservers]
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 connectivity:
ansible all -i inventory/production.ini -m ping
Step 2: Define Group Variables
nano group_vars/all.yml
---
# System configuration
deploy_user: deploy
deploy_user_password: "{{ vault_deploy_password }}"
# SSH hardening
ssh_port: 22
ssh_permit_root_login: "no"
ssh_password_authentication: "no"
ssh_allowed_users:
- deploy
# Firewall allowed ports
ufw_allowed_ports:
- { port: "{{ ssh_port }}", proto: tcp }
- { port: 80, proto: tcp }
- { port: 443, proto: tcp }
# Nginx
nginx_worker_processes: auto
nginx_worker_connections: 1024
# Timezone
server_timezone: UTC
Step 3: Write the Common Role
The common role handles tasks that apply to every server:
nano roles/common/tasks/main.yml
---
- name: Update apt cache and upgrade packages
apt:
update_cache: yes
upgrade: dist
cache_valid_time: 3600
- name: Install essential packages
apt:
name:
- ufw
- fail2ban
- unattended-upgrades
- curl
- git
- htop
- vim
- acl
state: present
- 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 authorized key for deploy user
authorized_key:
user: "{{ deploy_user }}"
key: "{{ lookup('file', '~/.ssh/id_ed25519.pub') }}"
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'
- name: Harden SSH configuration
template:
src: sshd_config.j2
dest: /etc/ssh/sshd_config
mode: '0600'
notify: restart sshd
- name: Configure UFW default policies
ufw:
direction: "{{ item.direction }}"
policy: "{{ item.policy }}"
loop:
- { direction: incoming, policy: deny }
- { direction: outgoing, policy: allow }
- name: Allow necessary ports through UFW
ufw:
rule: allow
port: "{{ item.port }}"
proto: "{{ item.proto }}"
loop: "{{ ufw_allowed_ports }}"
- name: Enable UFW
ufw:
state: enabled
- name: Configure Fail2ban
copy:
dest: /etc/fail2ban/jail.local
content: |
[DEFAULT]
bantime = 3600
findtime = 600
maxretry = 3
[sshd]
enabled = true
port = {{ ssh_port }}
notify: restart fail2ban
- 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";
nano roles/common/handlers/main.yml
---
- name: restart sshd
service:
name: sshd
state: restarted
- name: restart fail2ban
service:
name: fail2ban
state: restarted
nano roles/common/templates/sshd_config.j2
Port {{ ssh_port }}
PermitRootLogin {{ ssh_permit_root_login }}
PasswordAuthentication {{ ssh_password_authentication }}
PubkeyAuthentication yes
AuthorizedKeysFile .ssh/authorized_keys
X11Forwarding no
PrintLastLog yes
MaxAuthTries 3
ClientAliveInterval 300
ClientAliveCountMax 2
AllowUsers {{ ssh_allowed_users | join(' ') }}
Step 4: Write the Nginx Role
nano roles/nginx/tasks/main.yml
---
- name: Install Nginx
apt:
name: nginx
state: present
- name: Configure Nginx main settings
template:
src: nginx.conf.j2
dest: /etc/nginx/nginx.conf
notify: reload nginx
- name: Remove default Nginx 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/handlers/main.yml
---
- name: reload nginx
service:
name: nginx
state: reloaded
Step 5: Write the MariaDB Role
nano roles/mariadb/tasks/main.yml
---
- name: Install MariaDB and Python MySQL library
apt:
name:
- mariadb-server
- python3-pymysql
state: present
- name: Start and enable MariaDB
service:
name: mariadb
state: started
enabled: yes
- name: Set MariaDB root password
mysql_user:
name: root
password: "{{ vault_db_root_password }}"
login_unix_socket: /var/run/mysqld/mysqld.sock
host_all: yes
state: present
- name: Remove anonymous MySQL users
mysql_user:
name: ''
host_all: yes
login_user: root
login_password: "{{ vault_db_root_password }}"
state: absent
- name: Remove test database
mysql_db:
name: test
login_user: root
login_password: "{{ vault_db_root_password }}"
state: absent
- name: Create application database
mysql_db:
name: "{{ app_db_name }}"
encoding: utf8mb4
collation: utf8mb4_unicode_ci
login_user: root
login_password: "{{ vault_db_root_password }}"
state: present
- name: Create application database user
mysql_user:
name: "{{ app_db_user }}"
password: "{{ vault_app_db_password }}"
priv: "{{ app_db_name }}.*:ALL"
login_user: root
login_password: "{{ vault_db_root_password }}"
state: present
Step 6: Protect Secrets with Ansible Vault
Never store passwords in plain text in your playbooks or variable files. Ansible Vault encrypts sensitive values:
ansible-vault create group_vars/vault.yml
vault_deploy_password: "deploy_user_password_here"
vault_db_root_password: "root_db_password_here"
vault_app_db_password: "app_db_password_here"
Reference vault variables in your all.yml by name. When running playbooks, provide the vault password:
ansible-playbook site.yml --ask-vault-pass
# Or use a vault password file (for CI/CD)
ansible-playbook site.yml --vault-password-file ~/.vault_pass
Step 7: Create the Master Playbook
nano site.yml
---
- name: Configure all servers (common hardening)
hosts: all
become: yes
vars_files:
- group_vars/vault.yml
roles:
- common
- name: Configure web servers
hosts: webservers
become: yes
vars_files:
- group_vars/vault.yml
roles:
- nginx
- name: Configure database servers
hosts: dbservers
become: yes
vars_files:
- group_vars/vault.yml
roles:
- mariadb
Step 8: Run the Playbook
# Dry run first (check mode — no changes made)
ansible-playbook -i inventory/production.ini site.yml --check
# Apply the configuration
ansible-playbook -i inventory/production.ini site.yml --ask-vault-pass
Ansible displays a task-by-task summary showing which tasks changed the server state and which were already in the desired state (shown as “ok”). On subsequent runs of the same playbook, all tasks should show “ok” rather than “changed” — confirming idempotency.
Integrating Ansible with GitHub Actions
Automate playbook runs on infrastructure changes by adding Ansible to your CI/CD pipeline:
name: Deploy Infrastructure
on:
push:
branches: [main]
paths: ['ansible/**']
jobs:
ansible:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Install Ansible
run: pip install ansible
- name: Run playbook
env:
ANSIBLE_VAULT_PASSWORD: ${{ secrets.VAULT_PASSWORD }}
run: |
echo "$ANSIBLE_VAULT_PASSWORD" > .vault_pass
ansible-playbook -i ansible/inventory/production.ini \
ansible/site.yml \
--vault-password-file .vault_pass
rm .vault_pass
Getting Started
Ansible works with any VPS that has SSH access — no special provider integration required. Start by applying the common role to a fresh Ubuntu VPS from VPS.DO, and your server reaches a hardened, production-ready state in under five minutes. Add more roles as you build out your infrastructure, and every new server you provision gets the same configuration automatically.
Conclusion
Ansible transforms VPS server setup from a manual, error-prone process into version-controlled, repeatable code. The playbook in this guide — covering user creation, SSH hardening, UFW firewall, Fail2ban, automatic updates, Nginx, and MariaDB — handles the most common VPS setup tasks. Store your playbooks in Git, protect secrets with Ansible Vault, and every new VPS you provision reaches the same secure, consistent state without a single manual command.