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:
sudo apt update
sudo apt install -y ansible
ansible --version | head -1On 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:
vps9 servers label web-1 --tags prod,web
vps9 servers label db-1 --tags prod,dbTags 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:
#!/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):
export VPS9_TOKEN="$(cat ~/.vps9-token)"
python3 vps9_inventory.py > inventory.yml5 servers, 7 groupsPart of what it writes:
# 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:
[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-newStrictHostKeyChecking=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
ansible-inventory --graph
ansible all -m pingThe graph, shortened:
@all:
|--@ungrouped:
|--@image_debian_12:
| |--db-1
|--@image_ubuntu_24_04:
| |--web-1
|--@tag_prod:
| |--db-1
| |--web-1
|--@tag_web:
| |--web-1ping 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:
- 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: reloadedA few choices worth knowing:
- Each SSH setting goes in a file of its own under
/etc/ssh/sshd_config.d/, checked withsshd -tbefore it's put in place, so a typo can't lock you out. - The sudoers file is checked with
visudothe same way. - SSH is reloaded, not restarted, and only when its settings changed.
- The service is
sshon Debian and Ubuntu andsshdon AlmaLinux, Rocky Linux and Fedora;ssh_servicepicks the right one.
Run it against one server first, then a group:
ansible-playbook site.yml --limit web-1
ansible-playbook site.yml --limit tag_prodPLAY RECAP *********************************************************************
web-1 : ok=9 changed=8 unreachable=0 failed=0 skipped=1 rescued=0 ignored=0Check it worked
Run it a second time. Nothing should change:
web-1 : ok=8 changed=0 unreachable=0 failed=0 skipped=1 rescued=0 ignored=0Then log in as the new user and ask SSH what it allows:
ssh deploy@203.0.113.2 'sudo -n true && echo "deploy has sudo"; sudo sshd -T | grep -i "^passwordauthentication"'deploy has sudo
passwordauthentication noKeep 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:
python3 vps9_inventory.py > inventory.yml && ansible-playbook site.ymlNew 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
- SSH hardening checklist, for more settings to put in the playbook.
- API tokens for CI and automation, to run the inventory from a pipeline.
- Automate snapshots before risky changes with the CLI, before a big playbook run.