The playbook finishes green. Thirty-odd tasks, nothing failed, and the recap says the box is done. Two weeks later it reboots after a kernel update and nobody can get in on the port the playbook supposedly set.
That is the real problem with trying to bootstrap a VPS with Ansible in a single run. The failures that cost you are not the ones that turn the recap red. They are the tasks that report changed, do nothing useful, and leave you believing the server is hardened when it is not.
This post covers the ordering that keeps you connected while you dismantle your own access, the silent failure modes that only surface on reboot, and how to make the second run of the playbook as safe as the first. The examples are Debian and Ubuntu flavoured with notes for RHEL-family images, and they assume a plain provider VPS from somewhere like InterServer, Hetzner, DigitalOcean or Vultr, not a pre-baked golden image.
What a one-run bootstrap actually has to survive
A bootstrap playbook is unusual because it modifies the thing it is standing on. Every other playbook you write assumes a working connection. This one has to change the user it connects as, the port it connects to, and the firewall that lets it connect at all, without dropping the run.
Four things break it, and they break it in different ways:
- The chicken-and-egg problem. Ansible needs Python on the target to run almost any module, and a minimal image may not have it.
- Lockout. You remove your own access before proving the replacement access works.
- Non-idempotent tasks. The first run is fine. The second run is where the damage happens.
- Missing collections. The playbook that worked on your laptop fails on the CI runner because half the modules do not ship with
ansible-core.
Everything below is organised around those four, because that is how they actually show up.
The chicken-and-egg problem: no Python, no modules
Most modern cloud images ship Python 3 and this never comes up. Minimal Debian netinstall images and some stripped provider templates do not, and then your very first task fails with a message about the interpreter before it has done anything at all.
The escape hatch is ansible.builtin.raw, which pipes a command straight down the SSH connection without needing Python on the other end. Pair it with gather_facts: false, because fact gathering is itself a Python module and will fail before your bootstrap task ever runs.
- name: Make the host manageable
hosts: new_servers
gather_facts: false
become: true
tasks:
- name: Check whether a Python interpreter exists
ansible.builtin.raw: test -e /usr/bin/python3
register: python_present
changed_when: false
failed_when: false
- name: Install Python if it is missing
ansible.builtin.raw: apt-get update && apt-get install -y python3
when: python_present.rc != 0
changed_when: true
- name: Gather facts now that we can
ansible.builtin.setup:
Two details worth understanding rather than copying. failed_when: false on the check turns a non-zero exit into data instead of a failure, so you can branch on it. And ansible.builtin.setup is the fact-gathering module called explicitly, which means from that point on ansible_distribution and friends are available even though the play started with gathering disabled.
If you are provisioning through a provider that supports user-data, doing this in cloud-init instead is cleaner. Let cloud-init handle first-boot truths, then let Ansible own everything that will change later.
Failure family one: locking yourself out while the playbook says ok
This is the one that actually costs a rebuild, and it is entirely an ordering problem. The tasks are all correct in isolation. Run them in the wrong sequence and the playbook cheerfully removes your way back in before it has confirmed the replacement works.
The order that survives
The rule is simple: nothing that removes access happens until something has proven the new access works. In practice:
- Connect as whatever the provider handed you, usually
rootor a sudo-capable default user. - Create the admin account, add it to the sudo or wheel group, install its authorized key.
- Write the sudoers drop-in and validate it with
visudo -cf. - Prove you can log in as that account and escalate. This is the step everyone skips.
- Install the firewall and allow SSH before enabling it.
- Write the sshd configuration, validate it, restart, reconnect on the new port and confirm.
- Only now disable root login and password authentication.
Step four is a real task, not a comment. It runs from the control node against the target, using the credentials you are about to depend on:
- name: Prove the new admin account can log in and escalate
ansible.builtin.command: id -u
become: true
changed_when: false
vars:
ansible_user: "{{ admin_user }}"
ansible_ssh_private_key_file: "{{ admin_key_path }}"
Setting connection variables at the task level means this single task uses the new identity while the rest of the play carries on as root. If the key is wrong or sudo is misconfigured, the play stops here with root login still intact, which is exactly what you want.
The socket activation trap
This is the failure that passes green and bites on reboot. On Ubuntu images from 22.10 onward, sshd is started through systemd socket activation rather than as a persistent service. ssh.socket owns the listening port, and Port in sshd_config is not what decides where the daemon listens.
So your playbook writes Port 2222, restarts the service, reports changed, and the box carries on listening on 22. Your existing session still works. Your firewall rule for 2222 looks fine. Nothing indicates a problem until something forces a reconnect.
The fix is to write a socket override as well, and to check which mechanism the image actually uses rather than assuming:
# Is the socket unit in play on this host?
systemctl is-enabled ssh.socket
# What is actually listening, and under which unit?
ss -tlnp | grep -E 'ssh|:22'
If ssh.socket is enabled, the override needs an empty ListenStream= first to clear the inherited default, then the real value. Miss the empty line and the host listens on both ports, which is worse than either outcome alone because your hardening looks applied and is not.
Honestly, the simpler answer for most single-server setups is to leave SSH on 22 and lean on key-only authentication plus a firewall source restriction. Moving the port cuts log noise from opportunistic scanners. It does not stop anyone who is actually looking at you.
Firewall ordering
UFW defaults to denying inbound traffic when you enable it. Enable first, add the SSH rule second, and the run dies mid-task with an UNREACHABLE that will not recover. Rules go in first, always:
- name: Allow SSH before the firewall is armed
community.general.ufw:
rule: allow
port: "{{ ssh_port }}"
proto: tcp
- name: Set default inbound policy
community.general.ufw:
direction: incoming
policy: deny
- name: Enable UFW
community.general.ufw:
state: enabled
Two extra things bite here. Provider-level firewalls, the ones configured in the control panel rather than on the host, are a second layer that Ansible has no visibility into. If you open a port on the host and traffic still does not arrive, check there before debugging iptables. And if you moved the SSH port, the host firewall rule and the provider firewall rule both need updating, in that order, before the daemon restarts.
Failure family two: the second run is the dangerous one
A bootstrap playbook you run once is a shell script with extra syntax. The value only appears when you can run it again on the same host, in six months, and have it either do nothing or fix drift. That is idempotency, and the usual killers are shell and command tasks with no guard.
An unguarded shell task reports changed every single time, which means any handler it notifies fires every single time. That is how a playbook you thought was a no-op ends up restarting sshd on a production box during a routine drift check.
Guard them properly:
creates:orremoves:makes the task skip when the target state already exists. Prefer this.changed_when: falsefor read-only commands so they stop lying about changing things.when:driven by a registered check, for anything conditional.
The other idempotency trap is lineinfile against sshd_config. It works, and it is fragile in a specific way: the regex has to match the shipped file, and different distributions ship different defaults, commented or not. Writing a drop-in file is more predictable:
- name: Write the sshd hardening drop-in
ansible.builtin.template:
src: 99-hardening.conf.j2
dest: /etc/ssh/sshd_config.d/99-hardening.conf
owner: root
group: root
mode: "0600"
notify: Restart sshd
- name: Validate the full sshd configuration
ansible.builtin.command: sshd -t
changed_when: false
Note that the validation runs against the whole configuration rather than the drop-in in isolation. A drop-in is not a valid standalone config file, so validate: on the template task with sshd -t -f %s will fail on a temporary file that has no host keys and no context. Run sshd -t as its own task afterwards instead, and let the handler restart only if it passes.
Two prerequisites for the drop-in approach. The base sshd_config needs an Include /etc/ssh/sshd_config.d/*.conf line, which recent Debian, Ubuntu and RHEL 9 images have but older ones may not. And in sshd configuration the first value for a keyword wins, so the include has to appear near the top of the file for your drop-in to override anything below it. Check both before relying on the mechanism.
Failure family three: the collections are not where you think
ansible-core ships the ansible.builtin namespace and essentially nothing else. The full ansible package bundles a large set of community collections, which is why a playbook that runs on your laptop can fail on a lean CI runner with a module-not-found error.
Things people assume are built in and are not: ufw and timezone live in community.general. authorized_key, sysctl and mount live in ansible.posix. Declare them and pin them:
# requirements.yml
collections:
- name: community.general
- name: ansible.posix
# install before the run, and in CI
ansible-galaxy collection install -r requirements.yml
Use fully qualified names everywhere. Short names still resolve, but they resolve through a lookup that can pick a different module when two collections define the same name. ansible-lint flags this and it is worth listening to.
Failure family four: secrets in the repository
Bootstrap playbooks accumulate secrets faster than anything else you write: initial passwords, API tokens for monitoring agents, TLS material, backup credentials. They also tend to be the first repository someone shares with a client.
Ansible Vault is the low-friction option and it is genuinely fine for a small estate. Encrypt the variable file, not the playbook, so diffs stay readable:
ansible-vault encrypt group_vars/all/vault.yml
ansible-playbook site.yml --ask-vault-pass
The honest trade-off: Vault gives you one shared passphrase with no rotation story and no audit trail. Once more than two people run the playbook, or a client needs their own credentials kept separate from yours, move to something with real access control. That is more moving parts, so do not reach for it on day one.
Putting it together: how to bootstrap a VPS with Ansible end to end
Structure matters less than order, but a shape that works well is a single site.yml calling roles in dependency order, with the risky access changes fenced off at the end.
# inventory.yml
new_servers:
hosts:
web01:
ansible_host: 203.0.113.10
ansible_user: root
ansible_port: 22
vars:
admin_user: deploy
ssh_port: 22
Then the run itself, in this order:
- Reachability. Python check, fact gathering, timezone, hostname, package cache refresh.
- Identity. Admin user, group membership, authorized key, sudoers drop-in with
visudo -cfvalidation. - Verification gate. The task shown earlier. Nothing destructive runs before this passes.
- Firewall. Rules first, policy second, enable third.
- Unattended updates.
unattended-upgradeson Debian and Ubuntu,dnf-automaticwith its systemd timer on RHEL-family hosts. - Brute-force protection. fail2ban with your own
/etc/fail2ban/jail.local, never edits tojail.conf. - Workload. Docker, a web server, a monitoring agent, whatever the box is for.
- Access teardown. Disable root SSH login, disable password authentication, restart sshd, reconnect, confirm.
When step eight changes the port or user, the play needs to actually reconnect rather than reusing its cached SSH connection. Ansible keeps a persistent connection per host, so a config change alone will not be picked up:
- name: Point subsequent tasks at the new identity
ansible.builtin.set_fact:
ansible_user: "{{ admin_user }}"
ansible_port: "{{ ssh_port }}"
- name: Drop the cached connection
ansible.builtin.meta: reset_connection
- name: Confirm the host answers on the new settings
ansible.builtin.wait_for_connection:
timeout: 60
meta: reset_connection does not accept a when conditional, so it runs on every pass. That is a small cost for a play that works on the first run and every run after.
Test it before you point it at anything real
The cheapest test is a disposable VPS. Spin one up, run the playbook, reboot it, run the playbook again, then destroy it. The reboot is the important part, because that is what exposes the socket activation problem and any service you enabled but never actually started.
# catch YAML and structural errors instantly
ansible-playbook site.yml --syntax-check
# style and correctness, including FQCN and idempotency smells
ansible-lint
# show what would change without changing it
ansible-playbook -i inventory.yml site.yml --check --diff
One caveat on --check: it is not fully reliable on a bootstrap playbook. Tasks that depend on earlier tasks having run will fail or report nonsense in check mode, because the earlier change did not really happen. Treat a clean check run as a weak signal, not proof.
Troubleshooting
UNREACHABLEon the very first task. Usually host key verification against a rebuilt host reusing an old IP. Remove the stale entry withssh-keygen -Rand re-add the correct one withssh-keyscan. Disabling host key checking globally makes this go away and removes a real protection, so do not.UNREACHABLEimmediately after a firewall task. You armed the firewall before allowing SSH. Recover through the provider console, not SSH.- “Missing sudo password”. The sudoers drop-in is not in effect or has bad syntax. Validate with
visudo -cfon the file itself before trusting it. - Module not found under an FQCN. The collection is not installed on the machine running the playbook. Confirm with
ansible-galaxy collection list. - New group membership ignored. Adding a user to
dockermid-play does not affect the already-open connection. Insertmeta: reset_connectionafter the group change. - fail2ban installed but not banning. On images without rsyslog,
/var/log/auth.logmay not exist, so a jail configured to read it never starts. Checkfail2ban-client statusand switch the jail to the systemd backend if the log file is absent. - Everything green, port unchanged. Socket activation. Check
systemctl is-enabled ssh.socketandss -tlnp.
Common mistakes
- Disabling root login in the same play that creates the replacement user, with no verification step between them.
- Enabling UFW before the SSH allow rule exists.
- Assuming
Portinsshd_configis authoritative on every distribution. - Unguarded
shelltasks that notify handlers, causing service restarts on every run. - Editing
jail.confinstead of creatingjail.local, so the next package update silently reverts it. - Committing plaintext secrets because “it is only a bootstrap playbook”.
- Testing on a host you cannot afford to lose, with no console access configured.
- Relying on
--checkas a substitute for a real run on a disposable host.
Best practices
- Know your recovery path before the first run. Provider console, serial console or recovery mode, tested once so you are not learning it under pressure.
- Put a verification task before every irreversible one, and let the play fail there rather than after.
- Prefer drop-in files over line edits for sshd, sudoers, fail2ban and sysctl. They survive package upgrades and are trivially removable.
- Validate configuration before restarting the service that reads it.
sshd -t,visudo -cf,nginx -t. - Pin collections in
requirements.ymland install them in CI, not just locally. - Run the playbook twice on a fresh host and read the second recap. Anything still reporting changed is a bug.
- Reboot once during testing. Half of the silent failures only appear after a restart.
- Get a monitoring agent on the box in the same run. A server that is hardened but unobserved is a server you will hear about from a customer first.
Frequently asked questions
Can you really bootstrap a VPS with Ansible in a single playbook run?
Yes, including changing the SSH user and port mid-run. The mechanism is set_fact to update the connection variables, meta: reset_connection to drop the cached connection, and wait_for_connection to confirm the new settings work before continuing. What you cannot do safely is change access and skip the confirmation.
Should I use cloud-init or Ansible for initial server setup?
Both, split by lifetime. cloud-init handles things that are true exactly once at first boot: the admin user, the SSH key, a minimal package set. Ansible handles everything that will be tuned again later. Putting hardening rules in cloud-init means the only way to change them is to rebuild the host.
Do I need Terraform or OpenTofu as well?
Only if you are creating the infrastructure, not just configuring it. Terraform and OpenTofu create the VPS, the DNS records and the provider firewall. Ansible configures what runs inside. For a handful of long-lived servers ordered through a provider panel, Ansible alone is enough and adding a second tool costs more than it returns.
Why does my playbook work the first time and fail the second?
Almost always because the first run changed the connection parameters and the inventory still describes the original ones. Either update the inventory to the post-bootstrap values and make the play tolerate both, or split bootstrap and steady-state into separate playbooks with separate inventory entries.
Is changing the SSH port worth doing?
It reduces log noise from untargeted scanning, which makes real events easier to spot. It is not a security control on its own. Key-only authentication, a default-deny firewall and fail2ban do the actual work. If moving the port complicates your automation, the honest answer is to leave it on 22.
How do I recover if the playbook locks me out?
Through the provider’s console or recovery environment, which is why you should know where it is before you need it. Most VPS providers offer a web-based console, some offer a rescue boot with the disk mounted. From there, revert the offending drop-in file or firewall rule, restart the service, and reconnect. Then add the verification step the playbook was missing.
Can this run against several servers at once?
Yes, but use serial: 1 the first few times. If there is an ordering bug in the play, running it against one host at a time means you lock yourself out of one server instead of a fleet.
The one thing worth remembering
When you bootstrap a VPS with Ansible, the individual tasks are the easy part. Creating a user, writing a firewall rule and hardening sshd are all well-documented and hard to get wrong in isolation. What decides whether the run leaves you with a working server or a rebuild is the sequence, and specifically whether you verify new access before removing old access.
Add the verification gate. Run the playbook twice. Reboot the box once. Those three habits catch nearly every silent failure described here, and they cost about ten minutes on a disposable host.
Need a bootstrap playbook you can actually rerun?
Most of the provisioning work I take on starts with a playbook that worked once and has been too frightening to run since. Things I help with:
- Building a bootstrap role that takes a blank VPS to a hardened, monitored server in one run, with a verification gate before every irreversible step.
- Auditing an existing playbook for idempotency, so the second and hundredth runs are as safe as the first.
- Untangling SSH, firewall and socket activation interactions on mixed Debian, Ubuntu and RHEL-family estates.
- Splitting cloud-init and Ansible responsibilities cleanly so hardening stays changeable after first boot.
- Moving secrets out of a repository into Vault or a managed secret store without breaking existing runs.
- Wiring the playbook into CI with linting, collection pinning and a disposable-host test that runs on every change.
Send me the playbook, a recap from a second run, or the output of ss -tlnp from the host that is behaving oddly, and I will tell you what I would change first.