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.