How to Automate VPS Server Setup with Ansible: Repeatable, Version-Controlled Infrastructure

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.

Fast • Reliable • Affordable VPS - DO It Now!

Get top VPS hosting with VPS.DO’s fast, low-cost plans. Try risk-free with our 7-day no-questions-asked refund and start today!