Manage VPSNine servers with Ansible

Build an Ansible inventory from the VPSNine API, grouped by tag, plan and image, and run a playbook that patches servers and locks SSH to keys.

Tested on Ubuntu 24.04 LTS

Before you start

  • Ansible 2.14 or later with the ansible.posix collection on your computer
  • A read-only VPSNine API token
  • SSH access as root to your servers with your key

To manage VPSNine servers with Ansible, generate an inventory file from the VPSNine API with a short Python script, then point your playbooks at it. The script puts every running server in groups by tag, plan and image (tag_prod, plan_standard, image_ubuntu_24_04), so a play can target "every production web server" without a hand-kept host list.

What we tested: the inventory script ran against a local copy of the VPSNine API, ansible-inventory --graph read its output, and the playbook ran with Ansible 2.16 against an Ubuntu 24.04 server with systemd: the first run changed 8 things, the second changed nothing, and afterwards the deploy user could sudo and SSH refused passwords.

Install Ansible

On Ubuntu 24.04 or Debian 12, the distribution's ansible package includes the ansible.posix collection the playbook uses:

bash
sudo apt update
sudo apt install -y ansible
ansible --version | head -1

On macOS, brew install ansible. With pip, pip install ansible (not ansible-core, which leaves out the collections).

Tag your servers

Groups come from tags, so give each server the tags you want to target, in the panel or with the vps9 CLI:

bash
vps9 servers label web-1 --tags prod,web
vps9 servers label db-1 --tags prod,db

Tags are lowercase letters, digits, dots, dashes, underscores and colons. Ansible group names only allow letters, digits and underscores, so the script turns ci-runner into tag_ci_runner.

Make the inventory

Save this as vps9_inventory.py. It uses only Python's standard library:

python
#!/usr/bin/env python3
"""Writes an Ansible inventory (YAML) of your running VPSNine servers, grouped by tag, plan and image."""
import json, os, re, sys, urllib.request

API = os.environ.get("VPS9_API_URL", "https://my.vpsnine.com/api/v1")
TOKEN = os.environ["VPS9_TOKEN"]


def group(prefix: str, value: str) -> str:
    return prefix + "_" + re.sub(r"[^a-z0-9_]", "_", value.lower())


req = urllib.request.Request(f"{API}/servers", headers={"Authorization": f"Bearer {TOKEN}", "Accept": "application/json"})
with urllib.request.urlopen(req, timeout=30) as res:
    servers = json.load(res)["data"]

hosts, groups = {}, {}
for s in servers:
    if s["state"] != "running" or not s["ipv4"]:
        continue
    name = s["hostname"]
    if name in hosts:  # two servers with one hostname: keep both, the second under its id
        print(f"warning: two servers are called {name}; the second is {name}-{s['id']}", file=sys.stderr)
        name = f"{name}-{s['id']}"
    hosts[name] = {"ansible_host": s["ipv4"], "vps9_id": s["id"], "vps9_plan": s["plan"], "vps9_image": s["image"]}
    for g in [group("tag", t) for t in s["tags"]] + [group("plan", s["plan"]), group("image", s["image"])]:
        groups.setdefault(g, []).append(name)

lines = ["# Made by vps9_inventory.py from the VPSNine API. Edits are overwritten.", "all:", "  vars:", "    ansible_user: root", "  hosts:"]
for name, v in sorted(hosts.items()):
    lines.append(f"    {name}:")
    lines += [f"      {k}: {json.dumps(val)}" for k, val in v.items()]
lines.append("  children:")
for g, members in sorted(groups.items()):
    lines += [f"    {g}:", "      hosts:"] + [f"        {m}:" for m in sorted(members)]
print("\n".join(lines))
print(f"{len(hosts)} servers, {len(groups)} groups", file=sys.stderr)

Run it with a read-only token, which is all it needs (how to make one):

bash
export VPS9_TOKEN="$(cat ~/.vps9-token)"
python3 vps9_inventory.py > inventory.yml
text
5 servers, 7 groups

Part of what it writes:

yaml
# Made by vps9_inventory.py from the VPSNine API. Edits are overwritten.
all:
  vars:
    ansible_user: root
  hosts:
    db-1:
      ansible_host: "203.0.113.3"
      vps9_id: "srv_rg4dqdidjp6ymjiy"
      vps9_plan: "standard"
      vps9_image: "debian-12"
    web-1:
      ansible_host: "203.0.113.2"
      vps9_id: "srv_hanqbtehmhdj5ufl"
      vps9_plan: "standard"
      vps9_image: "ubuntu-24.04"
  children:
    image_debian_12:
      hosts:
        db-1:
    tag_prod:
      hosts:
        db-1:
        web-1:
    tag_web:
      hosts:
        web-1:

If two servers share a hostname, the second is listed as hostname-srv_… and the script prints a warning; give servers unique hostnames to avoid that. Servers that are stopped, being built or in rescue are left out, so a play never waits on a machine that can't answer. Each host also gets vps9_id, vps9_plan and vps9_image as variables, for templates or for calling the API from a play.

Tell Ansible to use it by default. Save this as ansible.cfg next to it:

ini
[defaults]
inventory = inventory.yml

[ssh_connection]
# Trust a new server's host key the first time; refuse it if it ever changes.
ssh_args = -C -o ControlMaster=auto -o ControlPersist=60s -o StrictHostKeyChecking=accept-new

StrictHostKeyChecking=accept-new trusts a server's host key the first time Ansible connects and refuses it if it ever changes, which suits servers you've just ordered. (Ansible's own host_key_checking = False would accept any key, every time; don't use it.) The other options are Ansible's defaults, which ssh_args replaces.

Check the inventory

bash
ansible-inventory --graph
ansible all -m ping

The graph, shortened:

text
@all:
  |--@ungrouped:
  |--@image_debian_12:
  |  |--db-1
  |--@image_ubuntu_24_04:
  |  |--web-1
  |--@tag_prod:
  |  |--db-1
  |  |--web-1
  |--@tag_web:
  |  |--web-1

ping should answer pong from each server. If one says UNREACHABLE, check you can ssh root@<its address> with your key.

An example playbook

Save this as site.yml. It installs updates and a few tools, creates a deploy user with your public key and sudo, turns off password logins for SSH, and starts fail2ban:

yaml
- name: Baseline for every VPSNine server
  hosts: all
  become: true
  vars:
    deploy_user: deploy
    deploy_key: "{{ lookup('file', '~/.ssh/id_ed25519.pub') }}"
    ssh_service: "{{ 'ssh' if ansible_facts['os_family'] == 'Debian' else 'sshd' }}"

  tasks:
    - name: Install updates (Debian and Ubuntu)
      ansible.builtin.apt:
        update_cache: true
        cache_valid_time: 3600
        upgrade: safe
      when: ansible_facts['os_family'] == 'Debian'

    - name: Install a few tools (Debian and Ubuntu)
      ansible.builtin.apt:
        name: [fail2ban, unattended-upgrades, htop]
      when: ansible_facts['os_family'] == 'Debian'

    - name: Install updates and a few tools (AlmaLinux, Rocky, Fedora)
      ansible.builtin.dnf:
        name: ["*", fail2ban, dnf-automatic, htop]
        state: latest
      when: ansible_facts['os_family'] == 'RedHat'

    - name: Create the deploy user
      ansible.builtin.user:
        name: "{{ deploy_user }}"
        shell: /bin/bash

    - name: Let the deploy user log in with your key
      ansible.posix.authorized_key:
        user: "{{ deploy_user }}"
        key: "{{ deploy_key }}"

    - name: Give the deploy user sudo
      ansible.builtin.copy:
        dest: "/etc/sudoers.d/90-{{ deploy_user }}"
        content: "{{ deploy_user }} ALL=(ALL) NOPASSWD:ALL\n"
        mode: "0440"
        validate: visudo -cf %s

    - name: Keys only for SSH
      ansible.builtin.copy:
        dest: /etc/ssh/sshd_config.d/10-keys-only.conf
        content: |
          PasswordAuthentication no
          KbdInteractiveAuthentication no
        mode: "0644"
        validate: sshd -t -f %s
      notify: Reload SSH

    - name: Start fail2ban now and at boot
      ansible.builtin.service:
        name: fail2ban
        state: started
        enabled: true

  handlers:
    - name: Reload SSH
      ansible.builtin.service:
        name: "{{ ssh_service }}"
        state: reloaded

A few choices worth knowing:

  • Each SSH setting goes in a file of its own under /etc/ssh/sshd_config.d/, checked with sshd -t before it's put in place, so a typo can't lock you out.
  • The sudoers file is checked with visudo the same way.
  • SSH is reloaded, not restarted, and only when its settings changed.
  • The service is ssh on Debian and Ubuntu and sshd on AlmaLinux, Rocky Linux and Fedora; ssh_service picks the right one.

Run it against one server first, then a group:

bash
ansible-playbook site.yml --limit web-1
ansible-playbook site.yml --limit tag_prod
text
PLAY RECAP *********************************************************************
web-1                      : ok=9    changed=8    unreachable=0    failed=0    skipped=1    rescued=0    ignored=0

Check it worked

Run it a second time. Nothing should change:

text
web-1                      : ok=8    changed=0    unreachable=0    failed=0    skipped=1    rescued=0    ignored=0

Then log in as the new user and ask SSH what it allows:

bash
ssh deploy@203.0.113.2 'sudo -n true && echo "deploy has sudo"; sudo sshd -T | grep -i "^passwordauthentication"'
text
deploy has sudo
passwordauthentication no

Keep the inventory fresh

The inventory is a snapshot of the API. Regenerate it before each run, for example in a small wrapper or a CI step:

bash
python3 vps9_inventory.py > inventory.yml && ansible-playbook site.yml

New servers can also come up with the basics already done by a setup script at order time, so the playbook only has to keep them that way.

Troubleshooting

ERROR! couldn't resolve module/action 'ansible.posix.authorized_key'. You have ansible-core without the collections. Install the full ansible package, or run ansible-galaxy collection install ansible.posix.

urllib.error.HTTPError: HTTP Error 401: Unauthorized from the script. VPS9_TOKEN is empty or the token was revoked. echo ${#VPS9_TOKEN} should print 57.

parameters are mutually exclusive: deb|package|upgrade. An apt task has both upgrade and name. Keep them in separate tasks, as in the playbook above.

WARNING: REMOTE HOST IDENTIFICATION HAS CHANGED! … Host key verification failed. The server's host key isn't the one you saw before. After a reinstall that's expected: remove the old key with ssh-keygen -R 203.0.113.2 and run again. If you didn't reinstall it, find out why before you connect.

A host is missing from the inventory. It isn't running, or has no IPv4 address. vps9 servers shows its state.

Next steps

Be first when orders open

Join the waitlist and we'll send you one email when VPSNine launches. Pick a plan if you already know which one you want, and we'll size the first servers around it.

  • One email, on launch day
  • Monthly or yearly, no lock-in
  • 14-day money-back guarantee