{"id":204,"date":"2026-08-13T21:00:00","date_gmt":"2026-08-13T18:00:00","guid":{"rendered":"https:\/\/john-nessime.com\/blog\/?p=204"},"modified":"2026-08-06T17:52:25","modified_gmt":"2026-08-06T14:52:25","slug":"bootstrap-vps-with-ansible","status":"publish","type":"post","link":"https:\/\/john-nessime.com\/blog\/devops\/bootstrap-vps-with-ansible\/","title":{"rendered":"Bootstrap a VPS With Ansible in One Run Without Locking Yourself Out"},"content":{"rendered":"\n<p class=\"wp-block-paragraph\">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.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">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 <code>changed<\/code>, do nothing useful, and leave you believing the server is hardened when it is not.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">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.<\/p>\n\n\n\n<h2 class=\"wp-block-heading\">What a one-run bootstrap actually has to survive<\/h2>\n\n\n\n<p class=\"wp-block-paragraph\">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.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">Four things break it, and they break it in different ways:<\/p>\n\n\n\n<ul class=\"wp-block-list\">\n<li><strong>The chicken-and-egg problem.<\/strong> Ansible needs Python on the target to run almost any module, and a minimal image may not have it.<\/li>\n\n<li><strong>Lockout.<\/strong> You remove your own access before proving the replacement access works.<\/li>\n\n<li><strong>Non-idempotent tasks.<\/strong> The first run is fine. The second run is where the damage happens.<\/li>\n\n<li><strong>Missing collections.<\/strong> The playbook that worked on your laptop fails on the CI runner because half the modules do not ship with <code>ansible-core<\/code>.<\/li>\n<\/ul>\n\n\n\n<p class=\"wp-block-paragraph\">Everything below is organised around those four, because that is how they actually show up.<\/p>\n\n\n\n<h2 class=\"wp-block-heading\">The chicken-and-egg problem: no Python, no modules<\/h2>\n\n\n\n<p class=\"wp-block-paragraph\">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.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">The escape hatch is <code>ansible.builtin.raw<\/code>, which pipes a command straight down the SSH connection without needing Python on the other end. Pair it with <code>gather_facts: false<\/code>, because fact gathering is itself a Python module and will fail before your bootstrap task ever runs.<\/p>\n\n\n\n<pre class=\"wp-block-code\"><code>- name: Make the host manageable\n  hosts: new_servers\n  gather_facts: false\n  become: true\n  tasks:\n    - name: Check whether a Python interpreter exists\n      ansible.builtin.raw: test -e \/usr\/bin\/python3\n      register: python_present\n      changed_when: false\n      failed_when: false\n\n    - name: Install Python if it is missing\n      ansible.builtin.raw: apt-get update &amp;&amp; apt-get install -y python3\n      when: python_present.rc != 0\n      changed_when: true\n\n    - name: Gather facts now that we can\n      ansible.builtin.setup:<\/code><\/pre>\n\n\n\n<p class=\"wp-block-paragraph\">Two details worth understanding rather than copying. <code>failed_when: false<\/code> on the check turns a non-zero exit into data instead of a failure, so you can branch on it. And <code>ansible.builtin.setup<\/code> is the fact-gathering module called explicitly, which means from that point on <code>ansible_distribution<\/code> and friends are available even though the play started with gathering disabled.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">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.<\/p>\n\n\n\n<h2 class=\"wp-block-heading\">Failure family one: locking yourself out while the playbook says ok<\/h2>\n\n\n\n<p class=\"wp-block-paragraph\">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.<\/p>\n\n\n\n<h3 class=\"wp-block-heading\">The order that survives<\/h3>\n\n\n\n<p class=\"wp-block-paragraph\">The rule is simple: <strong>nothing that removes access happens until something has proven the new access works.<\/strong> In practice:<\/p>\n\n\n\n<ol class=\"wp-block-list\">\n<li>Connect as whatever the provider handed you, usually <code>root<\/code> or a sudo-capable default user.<\/li>\n\n<li>Create the admin account, add it to the sudo or wheel group, install its authorized key.<\/li>\n\n<li>Write the sudoers drop-in and validate it with <code>visudo -cf<\/code>.<\/li>\n\n<li><strong>Prove you can log in as that account and escalate.<\/strong> This is the step everyone skips.<\/li>\n\n<li>Install the firewall and allow SSH <em>before<\/em> enabling it.<\/li>\n\n<li>Write the sshd configuration, validate it, restart, reconnect on the new port and confirm.<\/li>\n\n<li>Only now disable root login and password authentication.<\/li>\n<\/ol>\n\n\n\n<p class=\"wp-block-paragraph\">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:<\/p>\n\n\n\n<pre class=\"wp-block-code\"><code>- name: Prove the new admin account can log in and escalate\n  ansible.builtin.command: id -u\n  become: true\n  changed_when: false\n  vars:\n    ansible_user: \"{{ admin_user }}\"\n    ansible_ssh_private_key_file: \"{{ admin_key_path }}\"<\/code><\/pre>\n\n\n\n<p class=\"wp-block-paragraph\">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.<\/p>\n\n\n\n<h3 class=\"wp-block-heading\">The socket activation trap<\/h3>\n\n\n\n<p class=\"wp-block-paragraph\">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. <code>ssh.socket<\/code> owns the listening port, and <code>Port<\/code> in <code>sshd_config<\/code> is not what decides where the daemon listens.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">So your playbook writes <code>Port 2222<\/code>, 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.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">The fix is to write a socket override as well, and to check which mechanism the image actually uses rather than assuming:<\/p>\n\n\n\n<pre class=\"wp-block-code\"><code># Is the socket unit in play on this host?\nsystemctl is-enabled ssh.socket\n\n# What is actually listening, and under which unit?\nss -tlnp | grep -E 'ssh|:22'<\/code><\/pre>\n\n\n\n<p class=\"wp-block-paragraph\">If <code>ssh.socket<\/code> is enabled, the override needs an empty <code>ListenStream=<\/code> 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.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">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.<\/p>\n\n\n\n<h3 class=\"wp-block-heading\">Firewall ordering<\/h3>\n\n\n\n<p class=\"wp-block-paragraph\">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 <code>UNREACHABLE<\/code> that will not recover. Rules go in first, always:<\/p>\n\n\n\n<pre class=\"wp-block-code\"><code>- name: Allow SSH before the firewall is armed\n  community.general.ufw:\n    rule: allow\n    port: \"{{ ssh_port }}\"\n    proto: tcp\n\n- name: Set default inbound policy\n  community.general.ufw:\n    direction: incoming\n    policy: deny\n\n- name: Enable UFW\n  community.general.ufw:\n    state: enabled<\/code><\/pre>\n\n\n\n<p class=\"wp-block-paragraph\">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.<\/p>\n\n\n\n<h2 class=\"wp-block-heading\">Failure family two: the second run is the dangerous one<\/h2>\n\n\n\n<p class=\"wp-block-paragraph\">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 <code>shell<\/code> and <code>command<\/code> tasks with no guard.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">An unguarded <code>shell<\/code> 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.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">Guard them properly:<\/p>\n\n\n\n<ul class=\"wp-block-list\">\n<li><code>creates:<\/code> or <code>removes:<\/code> makes the task skip when the target state already exists. Prefer this.<\/li>\n\n<li><code>changed_when: false<\/code> for read-only commands so they stop lying about changing things.<\/li>\n\n<li><code>when:<\/code> driven by a registered check, for anything conditional.<\/li>\n<\/ul>\n\n\n\n<p class=\"wp-block-paragraph\">The other idempotency trap is <code>lineinfile<\/code> against <code>sshd_config<\/code>. 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:<\/p>\n\n\n\n<pre class=\"wp-block-code\"><code>- name: Write the sshd hardening drop-in\n  ansible.builtin.template:\n    src: 99-hardening.conf.j2\n    dest: \/etc\/ssh\/sshd_config.d\/99-hardening.conf\n    owner: root\n    group: root\n    mode: \"0600\"\n  notify: Restart sshd\n\n- name: Validate the full sshd configuration\n  ansible.builtin.command: sshd -t\n  changed_when: false<\/code><\/pre>\n\n\n\n<p class=\"wp-block-paragraph\">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 <code>validate:<\/code> on the template task with <code>sshd -t -f %s<\/code> will fail on a temporary file that has no host keys and no context. Run <code>sshd -t<\/code> as its own task afterwards instead, and let the handler restart only if it passes.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">Two prerequisites for the drop-in approach. The base <code>sshd_config<\/code> needs an <code>Include \/etc\/ssh\/sshd_config.d\/*.conf<\/code> line, which recent Debian, Ubuntu and RHEL 9 images have but older ones may not. And in sshd configuration the <em>first<\/em> 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.<\/p>\n\n\n\n<h2 class=\"wp-block-heading\">Failure family three: the collections are not where you think<\/h2>\n\n\n\n<p class=\"wp-block-paragraph\"><code>ansible-core<\/code> ships the <code>ansible.builtin<\/code> namespace and essentially nothing else. The full <code>ansible<\/code> 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.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">Things people assume are built in and are not: <code>ufw<\/code> and <code>timezone<\/code> live in <code>community.general<\/code>. <code>authorized_key<\/code>, <code>sysctl<\/code> and <code>mount<\/code> live in <code>ansible.posix<\/code>. Declare them and pin them:<\/p>\n\n\n\n<pre class=\"wp-block-code\"><code># requirements.yml\ncollections:\n  - name: community.general\n  - name: ansible.posix\n\n# install before the run, and in CI\nansible-galaxy collection install -r requirements.yml<\/code><\/pre>\n\n\n\n<p class=\"wp-block-paragraph\">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. <code>ansible-lint<\/code> flags this and it is worth listening to.<\/p>\n\n\n\n<h2 class=\"wp-block-heading\">Failure family four: secrets in the repository<\/h2>\n\n\n\n<p class=\"wp-block-paragraph\">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.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">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:<\/p>\n\n\n\n<pre class=\"wp-block-code\"><code>ansible-vault encrypt group_vars\/all\/vault.yml\nansible-playbook site.yml --ask-vault-pass<\/code><\/pre>\n\n\n\n<p class=\"wp-block-paragraph\">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.<\/p>\n\n\n\n<hr class=\"wp-block-separator has-alpha-channel-opacity\"\/>\n\n\n\n<h2 class=\"wp-block-heading\">Putting it together: how to bootstrap a VPS with Ansible end to end<\/h2>\n\n\n\n<p class=\"wp-block-paragraph\">Structure matters less than order, but a shape that works well is a single <code>site.yml<\/code> calling roles in dependency order, with the risky access changes fenced off at the end.<\/p>\n\n\n\n<pre class=\"wp-block-code\"><code># inventory.yml\nnew_servers:\n  hosts:\n    web01:\n      ansible_host: 203.0.113.10\n      ansible_user: root\n      ansible_port: 22\n  vars:\n    admin_user: deploy\n    ssh_port: 22<\/code><\/pre>\n\n\n\n<p class=\"wp-block-paragraph\">Then the run itself, in this order:<\/p>\n\n\n\n<ol class=\"wp-block-list\">\n<li><strong>Reachability.<\/strong> Python check, fact gathering, timezone, hostname, package cache refresh.<\/li>\n\n<li><strong>Identity.<\/strong> Admin user, group membership, authorized key, sudoers drop-in with <code>visudo -cf<\/code> validation.<\/li>\n\n<li><strong>Verification gate.<\/strong> The task shown earlier. Nothing destructive runs before this passes.<\/li>\n\n<li><strong>Firewall.<\/strong> Rules first, policy second, enable third.<\/li>\n\n<li><strong>Unattended updates.<\/strong> <code>unattended-upgrades<\/code> on Debian and Ubuntu, <code>dnf-automatic<\/code> with its systemd timer on RHEL-family hosts.<\/li>\n\n<li><strong>Brute-force protection.<\/strong> fail2ban with your own <code>\/etc\/fail2ban\/jail.local<\/code>, never edits to <code>jail.conf<\/code>.<\/li>\n\n<li><strong>Workload.<\/strong> Docker, a web server, a monitoring agent, whatever the box is for.<\/li>\n\n<li><strong>Access teardown.<\/strong> Disable root SSH login, disable password authentication, restart sshd, reconnect, confirm.<\/li>\n<\/ol>\n\n\n\n<p class=\"wp-block-paragraph\">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:<\/p>\n\n\n\n<pre class=\"wp-block-code\"><code>- name: Point subsequent tasks at the new identity\n  ansible.builtin.set_fact:\n    ansible_user: \"{{ admin_user }}\"\n    ansible_port: \"{{ ssh_port }}\"\n\n- name: Drop the cached connection\n  ansible.builtin.meta: reset_connection\n\n- name: Confirm the host answers on the new settings\n  ansible.builtin.wait_for_connection:\n    timeout: 60<\/code><\/pre>\n\n\n\n<p class=\"wp-block-paragraph\"><code>meta: reset_connection<\/code> does not accept a <code>when<\/code> 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.<\/p>\n\n\n\n<h2 class=\"wp-block-heading\">Test it before you point it at anything real<\/h2>\n\n\n\n<p class=\"wp-block-paragraph\">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.<\/p>\n\n\n\n<pre class=\"wp-block-code\"><code># catch YAML and structural errors instantly\nansible-playbook site.yml --syntax-check\n\n# style and correctness, including FQCN and idempotency smells\nansible-lint\n\n# show what would change without changing it\nansible-playbook -i inventory.yml site.yml --check --diff<\/code><\/pre>\n\n\n\n<p class=\"wp-block-paragraph\">One caveat on <code>--check<\/code>: 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.<\/p>\n\n\n\n<h2 class=\"wp-block-heading\">Troubleshooting<\/h2>\n\n\n\n<ul class=\"wp-block-list\">\n<li><strong><code>UNREACHABLE<\/code> on the very first task.<\/strong> Usually host key verification against a rebuilt host reusing an old IP. Remove the stale entry with <code>ssh-keygen -R<\/code> and re-add the correct one with <code>ssh-keyscan<\/code>. Disabling host key checking globally makes this go away and removes a real protection, so do not.<\/li>\n\n<li><strong><code>UNREACHABLE<\/code> immediately after a firewall task.<\/strong> You armed the firewall before allowing SSH. Recover through the provider console, not SSH.<\/li>\n\n<li><strong>&#8220;Missing sudo password&#8221;.<\/strong> The sudoers drop-in is not in effect or has bad syntax. Validate with <code>visudo -cf<\/code> on the file itself before trusting it.<\/li>\n\n<li><strong>Module not found under an FQCN.<\/strong> The collection is not installed on the machine running the playbook. Confirm with <code>ansible-galaxy collection list<\/code>.<\/li>\n\n<li><strong>New group membership ignored.<\/strong> Adding a user to <code>docker<\/code> mid-play does not affect the already-open connection. Insert <code>meta: reset_connection<\/code> after the group change.<\/li>\n\n<li><strong>fail2ban installed but not banning.<\/strong> On images without rsyslog, <code>\/var\/log\/auth.log<\/code> may not exist, so a jail configured to read it never starts. Check <code>fail2ban-client status<\/code> and switch the jail to the systemd backend if the log file is absent.<\/li>\n\n<li><strong>Everything green, port unchanged.<\/strong> Socket activation. Check <code>systemctl is-enabled ssh.socket<\/code> and <code>ss -tlnp<\/code>.<\/li>\n<\/ul>\n\n\n\n<h2 class=\"wp-block-heading\">Common mistakes<\/h2>\n\n\n\n<ul class=\"wp-block-list\">\n<li>Disabling root login in the same play that creates the replacement user, with no verification step between them.<\/li>\n\n<li>Enabling UFW before the SSH allow rule exists.<\/li>\n\n<li>Assuming <code>Port<\/code> in <code>sshd_config<\/code> is authoritative on every distribution.<\/li>\n\n<li>Unguarded <code>shell<\/code> tasks that notify handlers, causing service restarts on every run.<\/li>\n\n<li>Editing <code>jail.conf<\/code> instead of creating <code>jail.local<\/code>, so the next package update silently reverts it.<\/li>\n\n<li>Committing plaintext secrets because &#8220;it is only a bootstrap playbook&#8221;.<\/li>\n\n<li>Testing on a host you cannot afford to lose, with no console access configured.<\/li>\n\n<li>Relying on <code>--check<\/code> as a substitute for a real run on a disposable host.<\/li>\n<\/ul>\n\n\n\n<h2 class=\"wp-block-heading\">Best practices<\/h2>\n\n\n\n<ul class=\"wp-block-list\">\n<li>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.<\/li>\n\n<li>Put a verification task before every irreversible one, and let the play fail there rather than after.<\/li>\n\n<li>Prefer drop-in files over line edits for sshd, sudoers, fail2ban and sysctl. They survive package upgrades and are trivially removable.<\/li>\n\n<li>Validate configuration before restarting the service that reads it. <code>sshd -t<\/code>, <code>visudo -cf<\/code>, <code>nginx -t<\/code>.<\/li>\n\n<li>Pin collections in <code>requirements.yml<\/code> and install them in CI, not just locally.<\/li>\n\n<li>Run the playbook twice on a fresh host and read the second recap. Anything still reporting changed is a bug.<\/li>\n\n<li>Reboot once during testing. Half of the silent failures only appear after a restart.<\/li>\n\n<li>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.<\/li>\n<\/ul>\n\n\n\n<h2 class=\"wp-block-heading\">Frequently asked questions<\/h2>\n\n\n\n<h3 class=\"wp-block-heading\">Can you really bootstrap a VPS with Ansible in a single playbook run?<\/h3>\n\n\n\n<p class=\"wp-block-paragraph\">Yes, including changing the SSH user and port mid-run. The mechanism is <code>set_fact<\/code> to update the connection variables, <code>meta: reset_connection<\/code> to drop the cached connection, and <code>wait_for_connection<\/code> to confirm the new settings work before continuing. What you cannot do safely is change access and skip the confirmation.<\/p>\n\n\n\n<h3 class=\"wp-block-heading\">Should I use cloud-init or Ansible for initial server setup?<\/h3>\n\n\n\n<p class=\"wp-block-paragraph\">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.<\/p>\n\n\n\n<h3 class=\"wp-block-heading\">Do I need Terraform or OpenTofu as well?<\/h3>\n\n\n\n<p class=\"wp-block-paragraph\">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.<\/p>\n\n\n\n<h3 class=\"wp-block-heading\">Why does my playbook work the first time and fail the second?<\/h3>\n\n\n\n<p class=\"wp-block-paragraph\">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.<\/p>\n\n\n\n<h3 class=\"wp-block-heading\">Is changing the SSH port worth doing?<\/h3>\n\n\n\n<p class=\"wp-block-paragraph\">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.<\/p>\n\n\n\n<h3 class=\"wp-block-heading\">How do I recover if the playbook locks me out?<\/h3>\n\n\n\n<p class=\"wp-block-paragraph\">Through the provider&#8217;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.<\/p>\n\n\n\n<h3 class=\"wp-block-heading\">Can this run against several servers at once?<\/h3>\n\n\n\n<p class=\"wp-block-paragraph\">Yes, but use <code>serial: 1<\/code> 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.<\/p>\n\n\n\n<h2 class=\"wp-block-heading\">The one thing worth remembering<\/h2>\n\n\n\n<p class=\"wp-block-paragraph\">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.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">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.<\/p>\n\n\n\n<hr class=\"wp-block-separator has-alpha-channel-opacity\"\/>\n\n\n\n<h2 class=\"wp-block-heading\">Need a bootstrap playbook you can actually rerun?<\/h2>\n\n\n\n<p class=\"wp-block-paragraph\">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:<\/p>\n\n\n\n<ul class=\"wp-block-list\">\n<li>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.<\/li>\n\n<li>Auditing an existing playbook for idempotency, so the second and hundredth runs are as safe as the first.<\/li>\n\n<li>Untangling SSH, firewall and socket activation interactions on mixed Debian, Ubuntu and RHEL-family estates.<\/li>\n\n<li>Splitting cloud-init and Ansible responsibilities cleanly so hardening stays changeable after first boot.<\/li>\n\n<li>Moving secrets out of a repository into Vault or a managed secret store without breaking existing runs.<\/li>\n\n<li>Wiring the playbook into CI with linting, collection pinning and a disposable-host test that runs on every change.<\/li>\n<\/ul>\n\n\n\n<p class=\"wp-block-paragraph\">Send me the playbook, a recap from a second run, or the output of <code>ss -tlnp<\/code> from the host that is behaving oddly, and I will tell you what I would change first.<\/p>\n\n\n\n<div class=\"wp-block-buttons is-layout-flex wp-block-buttons-is-layout-flex\">\n<div class=\"wp-block-button\"><a class=\"wp-block-button__link wp-element-button\" href=\"https:\/\/www.upwork.com\/freelancers\/~01f15a912ad84a6620\" target=\"_blank\" rel=\"noreferrer noopener\">Work with me on Upwork<\/a><\/div>\n<\/div>\n","protected":false},"excerpt":{"rendered":"<p>A single Ansible run can take a blank VPS to a hardened, reproducible server. The hard part is not the tasks, it is the order. Here is the ordering that keeps you logged in, the failures that pass green and bite two weeks later, and how to make the second run as safe as the first.<\/p>\n","protected":false},"author":1,"featured_media":205,"comment_status":"open","ping_status":"open","sticky":false,"template":"","format":"standard","meta":{"footnotes":""},"categories":[24,26,63,52],"tags":[294,315,314,94,295,144,142,313,91,6,141,139,296,140,72,97,143,138],"class_list":["post-204","post","type-post","status-publish","format-standard","has-post-thumbnail","hentry","category-devops","category-linux","category-system-administration","category-technical-guides","tag-ansible","tag-ansible-collections","tag-ansible-vault","tag-automation","tag-cloud-init","tag-fail2ban","tag-firewall","tag-idempotency","tag-infrastructure-as-code","tag-linux","tag-openssh","tag-server-hardening","tag-server-provisioning","tag-ssh","tag-sysadmin","tag-systemd","tag-ufw","tag-vps","entry","has-media"],"yoast_head":"<!-- This site is optimized with the Yoast SEO plugin v28.2 - https:\/\/yoast.com\/product\/yoast-seo-wordpress\/ -->\n<title>Bootstrap a VPS With Ansible: One Run, No Lockout<\/title>\n<meta name=\"description\" content=\"How to bootstrap a VPS with Ansible in a single run: the task order that avoids SSH lockout, safe reruns, and the failures that pass green and bite later.\" \/>\n<meta name=\"robots\" content=\"index, follow, max-snippet:-1, max-image-preview:large, max-video-preview:-1\" \/>\n<link rel=\"canonical\" href=\"https:\/\/john-nessime.com\/blog\/devops\/bootstrap-vps-with-ansible\/\" \/>\n<meta property=\"og:locale\" content=\"en_US\" \/>\n<meta property=\"og:type\" content=\"article\" \/>\n<meta property=\"og:title\" content=\"Bootstrap a VPS With Ansible: One Run, No Lockout\" \/>\n<meta property=\"og:description\" content=\"How to bootstrap a VPS with Ansible in a single run: the task order that avoids SSH lockout, safe reruns, and the failures that pass green and bite later.\" \/>\n<meta property=\"og:url\" content=\"https:\/\/john-nessime.com\/blog\/devops\/bootstrap-vps-with-ansible\/\" \/>\n<meta property=\"og:site_name\" content=\"John Nessime\" \/>\n<meta property=\"article:published_time\" content=\"2026-08-13T18:00:00+00:00\" \/>\n<meta property=\"og:image\" content=\"https:\/\/john-nessime.com\/blog\/wp-content\/uploads\/2026\/08\/bootstrap-vps-with-ansible.png\" \/>\n\t<meta property=\"og:image:width\" content=\"1200\" \/>\n\t<meta property=\"og:image:height\" content=\"627\" \/>\n\t<meta property=\"og:image:type\" content=\"image\/png\" \/>\n<meta name=\"author\" content=\"John Nessime\" \/>\n<meta name=\"twitter:card\" content=\"summary_large_image\" \/>\n<meta name=\"twitter:label1\" content=\"Written by\" \/>\n\t<meta name=\"twitter:data1\" content=\"John Nessime\" \/>\n\t<meta name=\"twitter:label2\" content=\"Est. reading time\" \/>\n\t<meta name=\"twitter:data2\" content=\"14 minutes\" \/>\n<script type=\"application\/ld+json\" class=\"yoast-schema-graph\">{\"@context\":\"https:\\\/\\\/schema.org\",\"@graph\":[{\"@type\":\"Article\",\"@id\":\"https:\\\/\\\/john-nessime.com\\\/blog\\\/devops\\\/bootstrap-vps-with-ansible\\\/#article\",\"isPartOf\":{\"@id\":\"https:\\\/\\\/john-nessime.com\\\/blog\\\/devops\\\/bootstrap-vps-with-ansible\\\/\"},\"author\":{\"name\":\"John Nessime\",\"@id\":\"https:\\\/\\\/john-nessime.com\\\/blog\\\/#\\\/schema\\\/person\\\/ede0b56d0c808f123f57d5d796902105\"},\"headline\":\"Bootstrap a VPS With Ansible in One Run Without Locking Yourself Out\",\"datePublished\":\"2026-08-13T18:00:00+00:00\",\"mainEntityOfPage\":{\"@id\":\"https:\\\/\\\/john-nessime.com\\\/blog\\\/devops\\\/bootstrap-vps-with-ansible\\\/\"},\"wordCount\":2973,\"commentCount\":0,\"publisher\":{\"@id\":\"https:\\\/\\\/john-nessime.com\\\/blog\\\/#\\\/schema\\\/person\\\/ede0b56d0c808f123f57d5d796902105\"},\"image\":{\"@id\":\"https:\\\/\\\/john-nessime.com\\\/blog\\\/devops\\\/bootstrap-vps-with-ansible\\\/#primaryimage\"},\"thumbnailUrl\":\"https:\\\/\\\/john-nessime.com\\\/blog\\\/wp-content\\\/uploads\\\/2026\\\/08\\\/bootstrap-vps-with-ansible.png\",\"keywords\":[\"Ansible\",\"Ansible Collections\",\"Ansible Vault\",\"Automation\",\"cloud-init\",\"Fail2ban\",\"Firewall\",\"Idempotency\",\"Infrastructure as Code\",\"Linux\",\"OpenSSH\",\"Server Hardening\",\"Server Provisioning\",\"SSH\",\"Sysadmin\",\"Systemd\",\"UFW\",\"VPS\"],\"articleSection\":[\"DevOps\",\"Linux\",\"System Administration\",\"Technical Guides\"],\"inLanguage\":\"en-US\",\"potentialAction\":[{\"@type\":\"CommentAction\",\"name\":\"Comment\",\"target\":[\"https:\\\/\\\/john-nessime.com\\\/blog\\\/devops\\\/bootstrap-vps-with-ansible\\\/#respond\"]}]},{\"@type\":\"WebPage\",\"@id\":\"https:\\\/\\\/john-nessime.com\\\/blog\\\/devops\\\/bootstrap-vps-with-ansible\\\/\",\"url\":\"https:\\\/\\\/john-nessime.com\\\/blog\\\/devops\\\/bootstrap-vps-with-ansible\\\/\",\"name\":\"Bootstrap a VPS With Ansible: One Run, No Lockout\",\"isPartOf\":{\"@id\":\"https:\\\/\\\/john-nessime.com\\\/blog\\\/#website\"},\"primaryImageOfPage\":{\"@id\":\"https:\\\/\\\/john-nessime.com\\\/blog\\\/devops\\\/bootstrap-vps-with-ansible\\\/#primaryimage\"},\"image\":{\"@id\":\"https:\\\/\\\/john-nessime.com\\\/blog\\\/devops\\\/bootstrap-vps-with-ansible\\\/#primaryimage\"},\"thumbnailUrl\":\"https:\\\/\\\/john-nessime.com\\\/blog\\\/wp-content\\\/uploads\\\/2026\\\/08\\\/bootstrap-vps-with-ansible.png\",\"datePublished\":\"2026-08-13T18:00:00+00:00\",\"description\":\"How to bootstrap a VPS with Ansible in a single run: the task order that avoids SSH lockout, safe reruns, and the failures that pass green and bite later.\",\"breadcrumb\":{\"@id\":\"https:\\\/\\\/john-nessime.com\\\/blog\\\/devops\\\/bootstrap-vps-with-ansible\\\/#breadcrumb\"},\"inLanguage\":\"en-US\",\"potentialAction\":[{\"@type\":\"ReadAction\",\"target\":[\"https:\\\/\\\/john-nessime.com\\\/blog\\\/devops\\\/bootstrap-vps-with-ansible\\\/\"]}]},{\"@type\":\"ImageObject\",\"inLanguage\":\"en-US\",\"@id\":\"https:\\\/\\\/john-nessime.com\\\/blog\\\/devops\\\/bootstrap-vps-with-ansible\\\/#primaryimage\",\"url\":\"https:\\\/\\\/john-nessime.com\\\/blog\\\/wp-content\\\/uploads\\\/2026\\\/08\\\/bootstrap-vps-with-ansible.png\",\"contentUrl\":\"https:\\\/\\\/john-nessime.com\\\/blog\\\/wp-content\\\/uploads\\\/2026\\\/08\\\/bootstrap-vps-with-ansible.png\",\"width\":1200,\"height\":627,\"caption\":\"Two Ansible task orderings compared on one timeline: the top sequence disables root before verifying the new login and hits a marked lockout point, the bottom sequence verifies login and firewall rules first and stays recoverable until the final step\"},{\"@type\":\"BreadcrumbList\",\"@id\":\"https:\\\/\\\/john-nessime.com\\\/blog\\\/devops\\\/bootstrap-vps-with-ansible\\\/#breadcrumb\",\"itemListElement\":[{\"@type\":\"ListItem\",\"position\":1,\"name\":\"Home\",\"item\":\"https:\\\/\\\/john-nessime.com\\\/blog\\\/\"},{\"@type\":\"ListItem\",\"position\":2,\"name\":\"Bootstrap a VPS With Ansible in One Run Without Locking Yourself Out\"}]},{\"@type\":\"WebSite\",\"@id\":\"https:\\\/\\\/john-nessime.com\\\/blog\\\/#website\",\"url\":\"https:\\\/\\\/john-nessime.com\\\/blog\\\/\",\"name\":\"John Nessime\",\"description\":\"Cloud, DevOps, Data &amp; AI \u2014 Built, Tested, Explained\",\"publisher\":{\"@id\":\"https:\\\/\\\/john-nessime.com\\\/blog\\\/#\\\/schema\\\/person\\\/ede0b56d0c808f123f57d5d796902105\"},\"potentialAction\":[{\"@type\":\"SearchAction\",\"target\":{\"@type\":\"EntryPoint\",\"urlTemplate\":\"https:\\\/\\\/john-nessime.com\\\/blog\\\/?s={search_term_string}\"},\"query-input\":{\"@type\":\"PropertyValueSpecification\",\"valueRequired\":true,\"valueName\":\"search_term_string\"}}],\"inLanguage\":\"en-US\"},{\"@type\":[\"Person\",\"Organization\"],\"@id\":\"https:\\\/\\\/john-nessime.com\\\/blog\\\/#\\\/schema\\\/person\\\/ede0b56d0c808f123f57d5d796902105\",\"name\":\"John Nessime\",\"image\":{\"@type\":\"ImageObject\",\"inLanguage\":\"en-US\",\"@id\":\"https:\\\/\\\/john-nessime.com\\\/blog\\\/wp-content\\\/uploads\\\/2026\\\/07\\\/cropped-jn.png\",\"url\":\"https:\\\/\\\/john-nessime.com\\\/blog\\\/wp-content\\\/uploads\\\/2026\\\/07\\\/cropped-jn.png\",\"contentUrl\":\"https:\\\/\\\/john-nessime.com\\\/blog\\\/wp-content\\\/uploads\\\/2026\\\/07\\\/cropped-jn.png\",\"width\":512,\"height\":512,\"caption\":\"John Nessime\"},\"logo\":{\"@id\":\"https:\\\/\\\/john-nessime.com\\\/blog\\\/wp-content\\\/uploads\\\/2026\\\/07\\\/cropped-jn.png\"},\"description\":\"AWS Certified Solutions Architect helping businesses build reliable cloud, data, reporting, and automation solutions. I help startups, agencies, and growing businesses replace manual processes and disconnected data with practical AWS architectures, clean data pipelines, useful dashboards, and maintainable automation.\",\"sameAs\":[\"https:\\\/\\\/john-nessime.com\\\/blog\",\"https:\\\/\\\/www.linkedin.com\\\/in\\\/john-m-nessime\"],\"url\":\"https:\\\/\\\/john-nessime.com\\\/blog\\\/author\\\/johnnessime\\\/\"}]}<\/script>\n<!-- \/ Yoast SEO plugin. -->","yoast_head_json":{"title":"Bootstrap a VPS With Ansible: One Run, No Lockout","description":"How to bootstrap a VPS with Ansible in a single run: the task order that avoids SSH lockout, safe reruns, and the failures that pass green and bite later.","robots":{"index":"index","follow":"follow","max-snippet":"max-snippet:-1","max-image-preview":"max-image-preview:large","max-video-preview":"max-video-preview:-1"},"canonical":"https:\/\/john-nessime.com\/blog\/devops\/bootstrap-vps-with-ansible\/","og_locale":"en_US","og_type":"article","og_title":"Bootstrap a VPS With Ansible: One Run, No Lockout","og_description":"How to bootstrap a VPS with Ansible in a single run: the task order that avoids SSH lockout, safe reruns, and the failures that pass green and bite later.","og_url":"https:\/\/john-nessime.com\/blog\/devops\/bootstrap-vps-with-ansible\/","og_site_name":"John Nessime","article_published_time":"2026-08-13T18:00:00+00:00","og_image":[{"width":1200,"height":627,"url":"https:\/\/john-nessime.com\/blog\/wp-content\/uploads\/2026\/08\/bootstrap-vps-with-ansible.png","type":"image\/png"}],"author":"John Nessime","twitter_card":"summary_large_image","twitter_misc":{"Written by":"John Nessime","Est. reading time":"14 minutes"},"schema":{"@context":"https:\/\/schema.org","@graph":[{"@type":"Article","@id":"https:\/\/john-nessime.com\/blog\/devops\/bootstrap-vps-with-ansible\/#article","isPartOf":{"@id":"https:\/\/john-nessime.com\/blog\/devops\/bootstrap-vps-with-ansible\/"},"author":{"name":"John Nessime","@id":"https:\/\/john-nessime.com\/blog\/#\/schema\/person\/ede0b56d0c808f123f57d5d796902105"},"headline":"Bootstrap a VPS With Ansible in One Run Without Locking Yourself Out","datePublished":"2026-08-13T18:00:00+00:00","mainEntityOfPage":{"@id":"https:\/\/john-nessime.com\/blog\/devops\/bootstrap-vps-with-ansible\/"},"wordCount":2973,"commentCount":0,"publisher":{"@id":"https:\/\/john-nessime.com\/blog\/#\/schema\/person\/ede0b56d0c808f123f57d5d796902105"},"image":{"@id":"https:\/\/john-nessime.com\/blog\/devops\/bootstrap-vps-with-ansible\/#primaryimage"},"thumbnailUrl":"https:\/\/john-nessime.com\/blog\/wp-content\/uploads\/2026\/08\/bootstrap-vps-with-ansible.png","keywords":["Ansible","Ansible Collections","Ansible Vault","Automation","cloud-init","Fail2ban","Firewall","Idempotency","Infrastructure as Code","Linux","OpenSSH","Server Hardening","Server Provisioning","SSH","Sysadmin","Systemd","UFW","VPS"],"articleSection":["DevOps","Linux","System Administration","Technical Guides"],"inLanguage":"en-US","potentialAction":[{"@type":"CommentAction","name":"Comment","target":["https:\/\/john-nessime.com\/blog\/devops\/bootstrap-vps-with-ansible\/#respond"]}]},{"@type":"WebPage","@id":"https:\/\/john-nessime.com\/blog\/devops\/bootstrap-vps-with-ansible\/","url":"https:\/\/john-nessime.com\/blog\/devops\/bootstrap-vps-with-ansible\/","name":"Bootstrap a VPS With Ansible: One Run, No Lockout","isPartOf":{"@id":"https:\/\/john-nessime.com\/blog\/#website"},"primaryImageOfPage":{"@id":"https:\/\/john-nessime.com\/blog\/devops\/bootstrap-vps-with-ansible\/#primaryimage"},"image":{"@id":"https:\/\/john-nessime.com\/blog\/devops\/bootstrap-vps-with-ansible\/#primaryimage"},"thumbnailUrl":"https:\/\/john-nessime.com\/blog\/wp-content\/uploads\/2026\/08\/bootstrap-vps-with-ansible.png","datePublished":"2026-08-13T18:00:00+00:00","description":"How to bootstrap a VPS with Ansible in a single run: the task order that avoids SSH lockout, safe reruns, and the failures that pass green and bite later.","breadcrumb":{"@id":"https:\/\/john-nessime.com\/blog\/devops\/bootstrap-vps-with-ansible\/#breadcrumb"},"inLanguage":"en-US","potentialAction":[{"@type":"ReadAction","target":["https:\/\/john-nessime.com\/blog\/devops\/bootstrap-vps-with-ansible\/"]}]},{"@type":"ImageObject","inLanguage":"en-US","@id":"https:\/\/john-nessime.com\/blog\/devops\/bootstrap-vps-with-ansible\/#primaryimage","url":"https:\/\/john-nessime.com\/blog\/wp-content\/uploads\/2026\/08\/bootstrap-vps-with-ansible.png","contentUrl":"https:\/\/john-nessime.com\/blog\/wp-content\/uploads\/2026\/08\/bootstrap-vps-with-ansible.png","width":1200,"height":627,"caption":"Two Ansible task orderings compared on one timeline: the top sequence disables root before verifying the new login and hits a marked lockout point, the bottom sequence verifies login and firewall rules first and stays recoverable until the final step"},{"@type":"BreadcrumbList","@id":"https:\/\/john-nessime.com\/blog\/devops\/bootstrap-vps-with-ansible\/#breadcrumb","itemListElement":[{"@type":"ListItem","position":1,"name":"Home","item":"https:\/\/john-nessime.com\/blog\/"},{"@type":"ListItem","position":2,"name":"Bootstrap a VPS With Ansible in One Run Without Locking Yourself Out"}]},{"@type":"WebSite","@id":"https:\/\/john-nessime.com\/blog\/#website","url":"https:\/\/john-nessime.com\/blog\/","name":"John Nessime","description":"Cloud, DevOps, Data &amp; AI \u2014 Built, Tested, Explained","publisher":{"@id":"https:\/\/john-nessime.com\/blog\/#\/schema\/person\/ede0b56d0c808f123f57d5d796902105"},"potentialAction":[{"@type":"SearchAction","target":{"@type":"EntryPoint","urlTemplate":"https:\/\/john-nessime.com\/blog\/?s={search_term_string}"},"query-input":{"@type":"PropertyValueSpecification","valueRequired":true,"valueName":"search_term_string"}}],"inLanguage":"en-US"},{"@type":["Person","Organization"],"@id":"https:\/\/john-nessime.com\/blog\/#\/schema\/person\/ede0b56d0c808f123f57d5d796902105","name":"John Nessime","image":{"@type":"ImageObject","inLanguage":"en-US","@id":"https:\/\/john-nessime.com\/blog\/wp-content\/uploads\/2026\/07\/cropped-jn.png","url":"https:\/\/john-nessime.com\/blog\/wp-content\/uploads\/2026\/07\/cropped-jn.png","contentUrl":"https:\/\/john-nessime.com\/blog\/wp-content\/uploads\/2026\/07\/cropped-jn.png","width":512,"height":512,"caption":"John Nessime"},"logo":{"@id":"https:\/\/john-nessime.com\/blog\/wp-content\/uploads\/2026\/07\/cropped-jn.png"},"description":"AWS Certified Solutions Architect helping businesses build reliable cloud, data, reporting, and automation solutions. I help startups, agencies, and growing businesses replace manual processes and disconnected data with practical AWS architectures, clean data pipelines, useful dashboards, and maintainable automation.","sameAs":["https:\/\/john-nessime.com\/blog","https:\/\/www.linkedin.com\/in\/john-m-nessime"],"url":"https:\/\/john-nessime.com\/blog\/author\/johnnessime\/"}]}},"_links":{"self":[{"href":"https:\/\/john-nessime.com\/blog\/wp-json\/wp\/v2\/posts\/204","targetHints":{"allow":["GET"]}}],"collection":[{"href":"https:\/\/john-nessime.com\/blog\/wp-json\/wp\/v2\/posts"}],"about":[{"href":"https:\/\/john-nessime.com\/blog\/wp-json\/wp\/v2\/types\/post"}],"author":[{"embeddable":true,"href":"https:\/\/john-nessime.com\/blog\/wp-json\/wp\/v2\/users\/1"}],"replies":[{"embeddable":true,"href":"https:\/\/john-nessime.com\/blog\/wp-json\/wp\/v2\/comments?post=204"}],"version-history":[{"count":1,"href":"https:\/\/john-nessime.com\/blog\/wp-json\/wp\/v2\/posts\/204\/revisions"}],"predecessor-version":[{"id":206,"href":"https:\/\/john-nessime.com\/blog\/wp-json\/wp\/v2\/posts\/204\/revisions\/206"}],"wp:featuredmedia":[{"embeddable":true,"href":"https:\/\/john-nessime.com\/blog\/wp-json\/wp\/v2\/media\/205"}],"wp:attachment":[{"href":"https:\/\/john-nessime.com\/blog\/wp-json\/wp\/v2\/media?parent=204"}],"wp:term":[{"taxonomy":"category","embeddable":true,"href":"https:\/\/john-nessime.com\/blog\/wp-json\/wp\/v2\/categories?post=204"},{"taxonomy":"post_tag","embeddable":true,"href":"https:\/\/john-nessime.com\/blog\/wp-json\/wp\/v2\/tags?post=204"}],"curies":[{"name":"wp","href":"https:\/\/api.w.org\/{rel}","templated":true}]}}