# 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.

Source: https://vpsnine.com/tutorials/manage-vpsnine-servers-with-ansible · Updated: 2026-10-21

Difficulty: intermediate · Time: about 35 minutes · Tested on: Ubuntu 24.04 LTS

Prerequisites:

- 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](https://vpsnine.com/help/install-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](https://vpsnine.com/help/api-tokens-for-automation)):

```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](https://vpsnine.com/help/setup-scripts) 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

- [SSH hardening checklist](https://vpsnine.com/help/ssh-hardening-checklist), for more settings to put in the playbook.
- [API tokens for CI and automation](https://vpsnine.com/help/api-tokens-for-automation), to run the inventory from a pipeline.
- [Automate snapshots before risky changes with the CLI](https://vpsnine.com/tutorials/automate-snapshots-before-risky-changes-cli), before a big playbook run.
