Deployment guide

Ansible Control Node

Overview

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 run from. This SecureAnyCloud image is that machine for Ubuntu 24.04 on Azure, and it ships ready to use rather than as a bare apt install. ansible-core is installed with every command-line tool 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 runs against the machine itself, so the control node proves it works before you point it at a single server of your own.

The image ships the free and open source, GPL-3.0 licensed ansible-core 2.21.2, the newest release at build time. It is installed from the official PyPI wheel and pinned by SHA-256 content hash.

What you get:

  • 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 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/inventory (hosts.yml, group_vars/ and 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 license inventory at /usr/share/sac-ansible/dependency-licences.txt
  • 24/7 SecureAnyCloud support

The collections that ship

The image includes seven curated collections, plus the two collections they declare as dependencies. Every one is pinned to an exact version and verified by content hash.

  • ansible.posix 2.2.2: core POSIX system management, including 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, including packages, users, ufw, snap, cloud and notification modules
  • community.crypto 3.3.0: X.509, private keys, certificate signing requests and 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 in 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

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 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. Run fleet playbooks with sudo.
  • host_key_checking is left on. This is Ansible's secure default, which many appliance images disable for convenience. It stops a control node from 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.

The split between what SecureAnyCloud does for you and what remains yours is set out in /usr/share/sac-ansible/SECURITY-RESPONSIBILITIES on the VM and in Access control below. As the operator, you are responsible for authorizing this node on your servers, restricting inbound SSH to this VM, and protecting your own vault password.

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 at the same time. Raising forks alone on a 2 vCPU machine will not make runs faster.

Ports

The appliance serves one port: 22/tcp (SSH), which you use to administer the control node. It is reachable on a fresh VM.

The offer advertises port 22 only, because that is the only port the appliance serves. Ansible reaches out to your servers over SSH, so 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 SecureAnyCloud, then select Create.
  • Choose your subscription, resource group and region.
  • Set the VM size to Standard_B2s or 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.
  • Select Review + create, then Create.

From the Azure CLI

az vm image terms accept --urn sac1647283583153:ansible-ubuntu-24-04:default:latest
az vm create \
  --resource-group my-resource-group \
  --name my-ansible-control-node \
  --image sac1647283583153:ansible-ubuntu-24-04:default:latest \
  --size Standard_B2s \
  --admin-username azureuser \
  --generate-ssh-keys \
  --public-ip-sku Standard

When the VM is running, connect over SSH:

ssh azureuser@<vm-ip>

What happens on first boot

ansible-firstboot.service runs once, before you log in. It does the following:

  • 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 that it completed:

sudo systemctl status ansible-firstboot.service --no-pager

The unit reports active (exited). This 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

The file shows 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 authorize this node elsewhere. It 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 by checking 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.

Ping the control node itself:

ansible control_node -m ansible.builtin.ping

A reply of "ping": "pong" means the engine, the configuration and the inventory all resolve.

Next, run the shipped self-test playbook. It gathers this machine's real facts, renders a report from them, and asserts that 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 the report the playbook wrote:

sudo cat /var/lib/sac-ansible/selftest-report.txt

Run the playbook 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 also ships on the image. It re-verifies the pinned versions, the license inventory, and that the fleet key was generated on this VM. It also performs a genuine SSH round trip against a transient probe account that it creates and destroys, including proof that a key which is not this machine's fleet key is refused:

sudo ansible-selftest.sh

On success, the output ends with ANSIBLE_SELFTEST_OK.

Authorizing the control node on your servers

Ansible is agentless, so 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. Run this from the control node:

ssh-copy-id -f -i /etc/ansible/keys/id_ed25519.pub <user>@<server>

If you manage the servers another way, such as cloud-init, an image of your own or a configuration you already run, place the contents of /etc/ansible/keys/id_ed25519.pub into that account's authorized_keys.

Next, 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 refuses to talk to a host whose key it has not seen. That refusal is a feature: it stops an impostor from collecting your fleet credential. Record the keys you expect, once:

sudo ssh-keyscan -H web01.example.internal >> /root/.ssh/known_hosts

Now ping the fleet:

sudo ansible fleet -m ansible.builtin.ping

Because the fleet key is root-only, run fleet commands with sudo. This 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 standard Ansible project. Roles you place 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

Lint the playbook, then run it in check mode:

ansible-lint /opt/ansible/project/playbooks/site.yml || true
sudo ansible-playbook /opt/ansible/project/playbooks/site.yml --check

--check is a preview mode: Ansible reports what would change and changes nothing. Drop it when you are happy with the result.

Where configuration comes from

Ansible reads the first configuration file it finds, in this order:

  • $ANSIBLE_CONFIG
  • ./ansible.cfg in the directory you run from
  • ~/.ansible.cfg
  • /etc/ansible/ansible.cfg, the file this image ships

A project-local ansible.cfg therefore overrides the system one entirely rather than merging with it. This is standard Ansible behavior, and it is deliberate here: your project stays in control. Inspect what is 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. 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. Do not use /etc/ansible/ansible.cfg, so that the setting stays with the project it belongs to. Never commit the password file to source control.

Extending the control node

A requirements file reproduces the exact shipped collection set:

cat /opt/ansible/project/collections/requirements.yml

To add a collection, install it with the Galaxy client. This example adds the Azure collection, for managing Azure resources from this node:

sudo ansible-galaxy collection install azure.azcollection -p /usr/share/ansible/collections

Some collections also need Python libraries. Install them into the appliance's own virtual environment so that 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 authorized 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 must authorize a new key everywhere.
  • Consider a dedicated, unprivileged login account on your managed servers with a scoped sudoers rule, rather than authorizing this node directly for root.
  • The full split of responsibilities ships on the VM:
cat /usr/share/sac-ansible/SECURITY-RESPONSIBILITIES

Provenance and licensing

Everything in the image is pinned and verified from the installed files rather than from a badge. View the provenance record:

cat /usr/share/sac-ansible/source-provenance.txt

An executable license gate grades every Python distribution in the appliance virtual environment and every installed collection. The build fails on any restrictive or unreadable license. The graded inventory ships with the image, and you can re-run the gate at any time:

awk -F'\t' 'NF>=5 && $1!~/^#/{print $4}' /usr/share/sac-ansible/dependency-licences.txt | sort | uniq -c /opt/ansible/venv/bin/python3 /usr/share/sac-ansible/licence-gate.py --selftest

Day-to-day operation

These commands are useful day to day:

ansible-doc -l ansible.posix ansible-doc -t module community.general.timezone ansible-inventory --list ansible-config dump --only-changed

Ansible is pinned so that the shipped image is exactly the version that was verified, but nothing stops you from moving forward. To upgrade ansible-core:

sudo /opt/ansible/venv/bin/pip install --no-cache-dir --upgrade ansible-core

The pin does not affect Ubuntu security updates. 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 happens 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 the key 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 the error 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 SecureAnyCloud image is backed by 24/7 support. Raise an issue through the Azure Marketplace listing, or contact SecureAnyCloud support directly.

Ansible is a registered trademark of Red Hat, Inc. SecureAnyCloud 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.

Deploy on Azure

Launch Ansible Control Node on Ubuntu 24.04 LTS on Azure with 24/7 support from SecureAnyCloud.

View this image

Need Help?

Our support team is available 24/7.

sales@secureanycloud.com

Deploy on Azure