Ansible Control Node on Ubuntu 24.04 on Azure User Guide
Overview
This guide covers the deployment and configuration of Ansible Control Node on Ubuntu 24.04 on Azure using cloudimg Azure Marketplace images. Ansible is an agentless automation engine: it configures servers, deploys applications and runs operational tasks from plain YAML playbooks, over ordinary SSH, with nothing to install on the machines it manages.
A control node is the machine those playbooks actually run from. This image is that machine, and it ships ready rather than as a bare apt install. ansible-core is installed with every command line on the system PATH, a curated set of collections is installed system wide, and a tuned configuration, an inventory tree and a project skeleton are already in place. A self test playbook ships with it and runs against the machine itself, so the control node proves it works before you have pointed it at a single server of your own.
The cloudimg image ships the free and open source, GPL-3.0 licensed ansible-core 2.21.2 — the newest release at build time — installed from the official PyPI wheel and pinned by SHA-256 content hash. Backed by 24/7 cloudimg support.

What is included:
- ansible-core 2.21.2 in a dedicated virtual environment, with
ansible,ansible-playbook,ansible-galaxy,ansible-vault,ansible-inventory,ansible-doc,ansible-config,ansible-console,ansible-pullandansible-lintall on the system PATH - Nine collections installed system wide at
/usr/share/ansible/collections, every tarball verified by SHA-256 before installation - The helper libraries a control node actually needs: jmespath (
json_query), netaddr (ipaddrfilters), passlib (password_hash) and ansible-lint - A tuned
/etc/ansible/ansible.cfgwith SSH pipelining, connection multiplexing, task profiling and host key checking left on - Inventory scaffolding at
/etc/ansible/inventory—hosts.yml,group_vars/,host_vars/— with acontrol_nodegroup wired to this machine and an emptyfleetgroup for your servers - A project skeleton at
/opt/ansible/projectwithplaybooks/,roles/, and acollections/requirements.ymlthat reproduces the exact shipped collection set - A self test playbook that runs against this machine over the local connection plugin, so the appliance proves itself with no customer infrastructure
- No key material in the image at all — the fleet keypair is generated on your VM, at your first boot
- A full dependency licence inventory at
/usr/share/cloudimg-ansible/dependency-licences.txt
The collections that ship
Seven curated collections, plus the two collections those declare as dependencies. Every one is pinned to an exact version and verified by content hash.
| Collection | Version | What it is for |
|---|---|---|
ansible.posix |
2.2.2 | Core POSIX system management — mounts, sysctl, firewalld, authorized_key, and the profile_tasks callback |
ansible.utils |
6.0.3 | Data and network filter plugins, including ipaddr |
community.general |
13.2.0 | The large general purpose module set — packages, users, ufw, snap, cloud and notification modules |
community.crypto |
3.3.0 | X.509, private keys, certificate signing requests, OpenSSH key management |
community.mysql |
5.0.2 | MySQL and MariaDB databases, users and replication |
community.postgresql |
4.2.0 | PostgreSQL databases, roles, privileges and extensions |
community.docker |
5.2.1 | Docker containers, images, networks and Compose |
ansible.mysql |
5.2.0 | Declared dependency of community.mysql |
community.library_inventory_filtering_v1 |
1.1.5 | Declared dependency of community.general and community.docker |
Cloud provider collections are deliberately not preinstalled: they pull large vendor SDK dependency trees, and which one you want depends on your estate. Add the one you need with ansible-galaxy, as shown in Extending the control node.
Security posture, stated plainly
A control node is the most security sensitive machine in an automation estate. It holds the credential that can reach every server it manages, usually with privilege escalation. Anyone who reaches root on this VM reaches your whole fleet.
This image is built around that fact:
- It contains no SSH keypair, no vault password, no Galaxy or Automation Hub token and no
authorized_keysof any kind. Every VM launched from this image generates its own Ed25519 fleet keypair on first boot, so no two machines share an identity. - If key material somehow already exists when first boot runs, first boot refuses and exits rather than silently reusing a baked identity.
- The fleet private key is
0600 root:rootinside a0700 root:rootdirectory, so an ordinary login on this machine cannot read the credential that reaches your servers. Fleet playbooks are run withsudo. host_key_checkingis left on — Ansible's secure default, which many appliance images disable for convenience. It is what stops a control node being tricked into handing its fleet credential to an impostor.- SSH on port 22 is the only routable listener. There is no daemon and no web control plane on this image.
What cloudimg does for you, and what remains yours, is set out in /usr/share/cloudimg-ansible/SECURITY-RESPONSIBILITIES on the VM and in Access control below. Authorising this node on your servers, restricting inbound SSH to this VM, and protecting your own vault password are your responsibility as the operator.
Prerequisites
- An Azure subscription with permission to create virtual machines
- An SSH key pair for VM access
- A VM size of
Standard_B2s(2 vCPU, 4 GiB) or larger
Standard_B2s is a genuine recommendation, not a squeeze. ansible-core is a fork per host command line tool with no resident daemon, so the appliance idles at essentially nothing and peaks with the configured forks (10 by default). That is comfortable for a few hundred managed hosts. Beyond that, raise forks in /etc/ansible/ansible.cfg and move to a larger size together — raising forks alone on a 2 vCPU machine will not make runs faster.
Ports this appliance actually serves
| Port | Protocol | Purpose | Reachable on a fresh VM |
|---|---|---|---|
| 22 | TCP | SSH — how you administer the control node | Yes |
The offer advertises port 22 only, because that is the only port the appliance serves. Ansible reaches out to your servers over SSH; nothing needs to reach in. Restrict inbound 22 to your own address range with a network security group.
Launching the VM
From the Azure Portal
- Search the Azure Marketplace for Ansible Control Node on Ubuntu 24.04 LTS by cloudimg
- Select Create
- Choose your subscription, resource group and region
- Set the VM size to
Standard_B2sor larger - Set the authentication type to SSH public key and supply your key
- Under Inbound port rules, allow SSH (22) — and restrict the source to your own address range
- Review and create
From the Azure CLI
az vm create \
--resource-group my-resource-group \
--name my-ansible-control-node \
--image cloudimg:ansible-ubuntu-24-04:default:latest \
--size Standard_B2s \
--admin-username azureuser \
--generate-ssh-keys \
--public-ip-sku Standard
Then connect:
ssh azureuser@<vm-ip>
What happens on first boot
ansible-firstboot.service runs once, before you log in. It:
- Refuses to continue if any key material already exists — a baked identity would mean every customer shared one credential
- Generates this machine's own Ed25519 fleet keypair at
/etc/ansible/keys/id_ed25519,0600 root:root - Mints a per instance control node id and records it in the inventory at
/etc/ansible/inventory/group_vars/control_node.yml - Writes the public key, its fingerprint and the control node id to
/root/ansible-credentials.txt,0600 root:root - Writes the login banner
Confirm it completed:
sudo systemctl status ansible-firstboot.service --no-pager
It reports active (exited). That is correct for a Type=oneshot unit that has done its work.
Retrieving your identity material
Everything unique to this machine is in one root only file:
sudo cat /root/ansible-credentials.txt
You will see your own ANSIBLE_CONTROL_NODE_ID and ANSIBLE_FLEET_KEY_FINGERPRINT — for the VM this guide was verified against they were <ANSIBLE_CONTROL_NODE_ID> and <ANSIBLE_FLEET_KEY_FINGERPRINT>. Yours will be different, because they are generated on your VM at your first boot.
A helper prints just the parts you need to authorise this node elsewhere, and never prints the private key:
sudo ansible-fleet-key.sh

Proving the control node works
The appliance can prove itself with no servers of your own. Start with what is installed:
ansible --version
ansible-galaxy collection list
ansible-inventory --graph
The inventory graph shows a control_node group containing localhost, and an empty fleet group waiting for your servers. Reach the control node itself:
ansible control_node -m ansible.builtin.ping
"ping": "pong" means the engine, the configuration and the inventory all resolve.
Now run the shipped self test playbook. It gathers this machine's real facts, renders a report from them, and asserts a curated collection's filter plugin resolves:
sudo ansible-playbook /opt/ansible/project/playbooks/selftest.yml
The recap reports ok=5 changed=1 failed=0. Read what it wrote:
sudo cat /var/lib/cloudimg-ansible/selftest-report.txt
Run it a second time and the recap reports changed=0. That is the point: a correctly written playbook describes a desired state, so once the machine is in that state there is nothing left to do. Re running it is safe.

A fuller release gate ships on the image. It re-verifies the pinned versions, the licence inventory, that the fleet key really was generated on this VM, and performs a genuine SSH round trip against a transient probe account it creates and destroys — including proving that a key which is not this machine's fleet key is refused:
sudo ansible-selftest.sh
It ends with ANSIBLE_SELFTEST_OK.
Authorising the control node on your servers
Ansible is agentless: your servers need no software installed. They only need to accept this control node's public key for a login account that can escalate to root.
On each server you want to manage, add this node's public key. From the control node:
ssh-copy-id -f -i /etc/ansible/keys/id_ed25519.pub <user>@<server>
If you manage the servers another way — cloud init, an image of your own, a configuration you already run — just place the contents of /etc/ansible/keys/id_ed25519.pub into that account's authorized_keys.
Then add the servers to the fleet group. Edit /etc/ansible/inventory/hosts.yml and replace the empty hosts: {} under fleet:
fleet:
hosts:
web01.example.internal:
db01.example.internal:
ansible_host: 10.0.2.14
Set the login account once, for the whole group, in /etc/ansible/inventory/group_vars/fleet.yml:
ansible_user: azureuser
That file already points ansible_ssh_private_key_file at this machine's fleet key.
Host key checking is on, so Ansible will refuse to talk to a host whose key it has not seen. That refusal is a feature — it is what stops an impostor collecting your fleet credential. Record the keys you expect, once:
sudo ssh-keyscan -H web01.example.internal >> /root/.ssh/known_hosts
Then reach the fleet:
sudo ansible fleet -m ansible.builtin.ping
Because the fleet key is root only, fleet commands are run with sudo. That is deliberate: the credential that reaches every server you manage should not be readable by an ordinary login on this machine.
Writing your first playbook
The project skeleton at /opt/ansible/project is a normal Ansible project. Roles placed in /opt/ansible/project/roles are already on the configured roles_path.
sudo tee /opt/ansible/project/playbooks/site.yml >/dev/null <<'YAML'
---
- name: Baseline every server in the fleet
hosts: fleet
become: true
tasks:
- name: Ensure the base packages are present
ansible.builtin.package:
name:
- curl
- rsync
state: present
- name: Ensure the timezone is UTC
community.general.timezone:
name: Etc/UTC
YAML
Check it before you run it, then run it:
ansible-lint /opt/ansible/project/playbooks/site.yml || true
sudo ansible-playbook /opt/ansible/project/playbooks/site.yml --check
--check is a dry run: Ansible reports what would change and changes nothing. Drop it when you are happy.
Where configuration comes from
Ansible reads the first configuration file it finds, in this order:
$ANSIBLE_CONFIG./ansible.cfgin the directory you run from~/.ansible.cfg/etc/ansible/ansible.cfg— the one this image ships
So a project local ansible.cfg overrides the system one entirely rather than merging with it. That is standard Ansible behaviour and it is deliberate here: your project stays in control. Inspect what is actually in effect at any time:
ansible-config dump --only-changed
Protecting secrets with ansible-vault
This image ships no vault password, deliberately. A vault password baked into a marketplace image would be public, and it protects your secrets, so it has to be yours.
Create an encrypted variables file:
sudo ansible-vault create /opt/ansible/project/group_vars/fleet/vault.yml
Ansible prompts for a password, opens an editor, and encrypts the file when you save. Reference the variables from a playbook exactly as you would plain ones, and supply the password at run time with --ask-vault-pass.
For unattended runs, put the password in a root only file and point vault_password_file at it in your project ansible.cfg — not in /etc/ansible/ansible.cfg, so it stays with the project it belongs to. Never commit that file to source control.
Extending the control node
The exact shipped collection set is reproducible from a requirements file:
cat /opt/ansible/project/collections/requirements.yml
Add a collection — for example the Azure one, if you want to manage Azure resources from here:
sudo ansible-galaxy collection install azure.azcollection -p /usr/share/ansible/collections
Some collections also need Python libraries. Install those into the appliance's own virtual environment so ansible can import them:
sudo /opt/ansible/venv/bin/pip install --no-cache-dir requests
Access control
- The fleet private key is
0600 root:rootin a0700 root:rootdirectory. Anyone who cansudoon this VM can use it against every server it is authorised on. - Restrict inbound SSH to this VM with a network security group. This is the single highest value control you have.
- Back up
/etc/ansible/keys. It is this machine's identity and exists nowhere else. If you lose it, you re authorise a new key everywhere. - Consider a dedicated, unprivileged login account on your managed servers with a scoped
sudoersrule, rather than authorising this node directly for root. - The full split of responsibilities ships on the VM:
cat /usr/share/cloudimg-ansible/SECURITY-RESPONSIBILITIES
Provenance and licensing
Everything in the image is pinned and verified from the artifact rather than from a badge:
cat /usr/share/cloudimg-ansible/source-provenance.txt
An executable licence gate grades every Python distribution in the appliance virtual environment and every installed collection, and the build fails on any restrictive or unreadable licence. The graded inventory ships with the image, and the gate can be re run at any time:
awk -F'\t' 'NF>=5 && $1!~/^#/{print $4}' /usr/share/cloudimg-ansible/dependency-licences.txt | sort | uniq -c
/opt/ansible/venv/bin/python3 /usr/share/cloudimg-ansible/licence-gate.py --selftest

Day to day operation
Useful commands:
ansible-doc -l ansible.posix
ansible-doc -t module community.general.timezone
ansible-inventory --list
ansible-config dump --only-changed
Keeping current — Ansible is pinned so that the shipped image is exactly the artifact that was verified, but nothing stops you moving forward:
sudo /opt/ansible/venv/bin/pip install --no-cache-dir --upgrade ansible-core
Ubuntu security updates are unaffected by that pin and unattended-upgrades is enabled on the image, so the base OS keeps patching itself.
Troubleshooting
ansible-playbook reports Ansible requires the locale encoding to be UTF-8 — you are running under LC_ALL=C. Use C.UTF-8 instead. This most often shows up in cron jobs and CI runners, which frequently set a bare C locale.
A managed host is UNREACHABLE with Host key verification failed — host key checking is on, and Ansible has not seen that host's key. Record it with ssh-keyscan -H <hostname> >> /root/.ssh/known_hosts, run as the same user that runs the playbook. Do not disable host key checking to make this go away.
A managed host is UNREACHABLE with Permission denied (publickey) — the control node's public key is not in that account's authorized_keys, or ansible_user in group_vars/fleet.yml names an account that does not exist there.
sudo ansible-fleet-key.sh says there is no fleet key — first boot has not completed. Check sudo systemctl status ansible-firstboot.service --no-pager and sudo journalctl -u ansible-firstboot.service.
A module reports a missing Python library — the collection needs a dependency that is not in the appliance virtual environment. Install it with sudo /opt/ansible/venv/bin/pip install <library>.
Playbook runs feel slow across many hosts — raise forks in /etc/ansible/ansible.cfg and move to a larger VM size at the same time. SSH pipelining and connection multiplexing are already enabled.
Support
Every cloudimg image is backed by 24/7 support. Raise an issue through the Azure Marketplace listing or contact cloudimg support directly.
Ansible is a registered trademark of Red Hat, Inc. cloudimg is not affiliated with, endorsed by, or sponsored by Red Hat. This image packages the upstream open source ansible-core distribution and community collections unmodified.