Developer Tools Azure

Ansible Control Node on Ubuntu 24.04 on Azure User Guide

| Product: Ansible Control Node on Ubuntu 24.04 LTS on Azure

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.

The Ansible control node reporting core 2.21.2 with its config file, module and collection paths, the nine installed collections at their pinned versions, the parsed inventory graph showing the control_node and fleet groups, and an ansible ping returning pong from localhost

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-pull and ansible-lint all 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 (ipaddr filters), passlib (password_hash) and ansible-lint
  • A tuned /etc/ansible/ansible.cfg with SSH pipelining, connection multiplexing, task profiling and host key checking left on
  • Inventory scaffolding at /etc/ansible/inventoryhosts.yml, group_vars/, host_vars/ — with a control_node group wired to this machine and an empty fleet group for your servers
  • A project skeleton at /opt/ansible/project with playbooks/, roles/, and a collections/requirements.yml that 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_keys of 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:root inside a 0700 root:root directory, so an ordinary login on this machine cannot read the credential that reaches your servers. Fleet playbooks are run with sudo.
  • host_key_checking is 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

  1. Search the Azure Marketplace for Ansible Control Node on Ubuntu 24.04 LTS by cloudimg
  2. Select Create
  3. Choose your subscription, resource group and region
  4. Set the VM size to Standard_B2s or larger
  5. Set the authentication type to SSH public key and supply your key
  6. Under Inbound port rules, allow SSH (22) — and restrict the source to your own address range
  7. 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:

  1. Refuses to continue if any key material already exists — a baked identity would mean every customer shared one credential
  2. Generates this machine's own Ed25519 fleet keypair at /etc/ansible/keys/id_ed25519, 0600 root:root
  3. Mints a per instance control node id and records it in the inventory at /etc/ansible/inventory/group_vars/control_node.yml
  4. Writes the public key, its fingerprint and the control node id to /root/ansible-credentials.txt, 0600 root:root
  5. 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

The fleet key helper printing this VM's SHA256 fingerprint and its Ed25519 public key, a listing of the key directory showing the private key at 0600 root root inside a 0700 directory, the per instance control node id and fingerprint from the credentials file, host key checking reported as True by ansible-config, the kernel's listening socket table showing only SSH routable, and the shipped release gate confirming the keypair was generated after this VM booted and that a stranger key is refused

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.

The self test playbook running five tasks with task level timings, reporting ok equals 5 and changed equals 1 with zero failures, the report it rendered showing this VM's control node id, hostname, distribution, kernel, vCPUs, memory, ansible-core version and Python version, and a second identical run reporting changed equals 0

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:

  1. $ANSIBLE_CONFIG
  2. ./ansible.cfg in the directory you run from
  3. ~/.ansible.cfg
  4. /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:root in a 0700 root:root directory. Anyone who can sudo on 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 sudoers rule, 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

The shipped provenance record showing ansible-core 2.21.2 from the PyPI wheel with its SHA-256 and its GPL-3.0 licence verified from the COPYING file inside the installed artifact, the nine pinned collections with their versions and which are dependencies, a count of all 46 graded artifacts showing every one resolved OK, and the licence gate's own 28 parser unit tests passing

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.