Ubuntu 26.04-based Docker image that packages Ansible, OpenSSH, and everything needed to manage remote infrastructure. Write your playbooks on the host, mount them into the container, and run — no need to install Ansible locally.
- Zero local dependencies — only Docker required on the host
- SSH built-in — connect into the controller or out to managed hosts
- Mount-based workflow — playbooks, inventory, and SSH keys live on the host; no rebuild needed to change them
- Multi-platform — ships
linux/amd64andlinux/arm64(Apple Silicon, AWS Graviton) - Auto-versioned — every push to
mainis automatically tagged via conventional commits - Published to two registries — Docker Hub and GitHub Container Registry (GHCR)
- Security hardened — non-root
ansibleuser,PermitRootLogin no, pip-upgraded CVE packages
- Prerequisites
- How it works
- Quick start
- Pull the image
- Makefile targets
- Running playbooks
- Adding roles from a GitHub repository
- Adding roles from Ansible Galaxy
- Ad-hoc commands
- Build from source
- Run with Docker (manual)
- Dynamic inventory
- Cloud dynamic inventory (AWS / Azure / GCP)
- Managing Windows hosts (WinRM)
- Ansible Vault
- Linting playbooks
- Faster runs with Mitogen
- SSH keys for managed hosts
- SSH agent forwarding
- Logs
- Versioning and releases
- Contributing
- License
- Notes
| Requirement | Minimum version | Notes |
|---|---|---|
| Docker Engine | 20.10+ | Install guide |
| Docker Compose | V2 (docker compose) |
Included with Docker Desktop |
No other tools required. Ansible runs entirely inside the container.
You write and store your playbooks on your host machine. The container provides Ansible and SSH. You mount your playbook directory into the container and tell Ansible where to find it.
Host machine Container
────────────────────────────── ────────────────────────────────
~/my-project/
playbooks/ ──mount──→ /configs/
site.yml playbooks/site.yml
roles/ roles/
inventory/ ──mount──→ inventory/hosts.ini
ssh/ ──mount──→ /home/ansible/.ssh/
id_ed25519 id_ed25519 (used to reach remote hosts)
The docker-compose.yml included in the repo already has all four mounts configured. If you add playbooks outside the playbooks/ directory, add an extra volume entry for that path.
git clone https://github.com/allamiro/ansible-controller.git
cd ansible-controllerThe repo already includes the full directory structure, docker-compose.yml, ansible.cfg, and example playbooks in playbooks/. Nothing to create manually.
# Edit configs/inventory/hosts.ini and list your servers
cat > configs/inventory/hosts.ini << 'EOF'
[all]
192.168.1.10
192.168.1.11
192.168.1.12
[webservers]
192.168.1.10
192.168.1.11
[databases]
192.168.1.12
EOF# Generate a key pair into ssh/
ssh-keygen -t ed25519 -C "ansible-controller" -f ssh/id_ed25519 -N ""
chmod 600 ssh/id_ed25519
# Copy the public key to every unique host in the inventory
for host in $(grep -v '^\[' configs/inventory/hosts.ini \
| grep -v '^#' \
| grep -v '^$' \
| sort -u); do
ssh-copy-id -i ssh/id_ed25519.pub user@$host
donedocker compose up -d# Run the included ping playbook against all servers
docker exec -it ansible-controller \
ansible-playbook /configs/playbooks/ping.ymlAll hosts should return pong. If they do, Ansible can reach your servers.
Drop your playbooks into the playbooks/ directory on the host:
# Example: create a simple playbook
cat > playbooks/deploy.yml << 'EOF'
---
- name: Deploy application
hosts: webservers
tasks:
- name: Ensure nginx is installed
ansible.builtin.apt:
name: nginx
state: present
become: true
EOF
# Run it
docker exec -it ansible-controller \
ansible-playbook /configs/playbooks/deploy.ymlmake shell
# or
docker exec -it ansible-controller bashDocker Hub
docker pull allamiro1/ansible-controller:latestGitHub Container Registry (GHCR)
docker pull ghcr.io/allamiro/ansible-controller:latest| Tag | Description |
|---|---|
latest |
Most recent successful build from main |
sha-XXXXXXX |
Immutable pointer to a specific commit — use for pinned/reproducible deployments |
v1.2.3 |
Semantic version — published when a v* git tag is pushed |
main |
Tracks the main branch |
| Target | Description |
|---|---|
make build |
Build the Docker image locally |
make up |
Start the container in the background |
make down |
Stop and remove the container |
make shell |
Open an interactive bash shell inside the container |
make run PLAYBOOK=site.yml |
Run an Ansible playbook |
make galaxy |
Install roles and collections from configs/requirements.yml |
make galaxy-force |
Re-install / update Galaxy content to the pinned versions |
make pip |
Install extra Python packages from configs/pip-requirements.txt |
make lint |
Lint everything under playbooks/ with ansible-lint |
make logs |
Tail container logs |
# Basic run against the default inventory in ansible.cfg
docker exec -it ansible-controller \
ansible-playbook /configs/playbooks/site.yml
# Specify a user to connect as on the remote hosts
docker exec -it ansible-controller \
ansible-playbook /configs/playbooks/site.yml -u deploy
# Specify a different inventory file
docker exec -it ansible-controller \
ansible-playbook /configs/playbooks/site.yml \
-i /configs/inventory/hosts.ini
# Run against a single host
docker exec -it ansible-controller \
ansible-playbook /configs/playbooks/site.yml \
-i "192.168.1.10," -u deploy
# Limit to a specific group or host from inventory
docker exec -it ansible-controller \
ansible-playbook /configs/playbooks/site.yml --limit webservers
# Pass extra variables
docker exec -it ansible-controller \
ansible-playbook /configs/playbooks/site.yml \
-e "env=production version=1.2.3"
# Run only tasks with specific tags
docker exec -it ansible-controller \
ansible-playbook /configs/playbooks/site.yml --tags "install,configure"
# Dry run — show what would change without applying it
docker exec -it ansible-controller \
ansible-playbook /configs/playbooks/site.yml --check --diff
# Increase verbosity for troubleshooting
docker exec -it ansible-controller \
ansible-playbook /configs/playbooks/site.yml -vvRoles must be reachable from inside the container. If your project layout is:
playbooks/
site.yml
roles/
webserver/
database/
They are already available at /configs/playbooks/roles/ inside the container. Reference them normally in your playbook:
- hosts: webservers
roles:
- webserver
- databaseIf roles live in a separate directory, mount them and set roles_path in configs/ansible.cfg:
[defaults]
roles_path = /configs/roles:/configs/playbooks/rolesAny role published as a git repository can be installed directly — useful for roles that aren't on Galaxy, forks, or a version pinned to a specific branch/tag/commit.
Roles are declared in configs/requirements.yml and installed into configs/.galaxy/ on the host (a read-write mount), so they persist across restarts and need no image rebuild. Everything declared there is installed automatically when the container starts, in the background (logged to logs/galaxy-install.log); run make galaxy after make up to install on demand — it shares a lock with the startup installer, so it also blocks until any in-flight startup install has finished, guaranteeing content is ready before you run playbooks. configs/ansible.cfg already points roles_path there, so installed roles resolve automatically.
Add a git source to configs/requirements.yml. For example, to install geerlingguy/ansible-role-nginx:
---
roles:
- src: https://github.com/geerlingguy/ansible-role-nginx
name: nginx # directory name the role installs as — reference this in playbooks
version: master # branch, tag, or commit SHA to pin tomake up # the container must be running
make galaxy # installs everything declared in requirements.ymlUse make galaxy-force later to update an already-installed role to the version in the file.
Reference the role by the name you set above:
- name: Configure web servers
hosts: webservers
roles:
- nginxmake run PLAYBOOK=site.ymlWhen a role is published on Ansible Galaxy, reference it by its Galaxy name (namespace.role) instead of a git URL. Galaxy also resolves the role's dependencies automatically.
---
roles:
- name: geerlingguy.nginx
version: 3.2.0 # pin so installs are reproducible
- name: geerlingguy.docker
version: 7.4.2
collections:
- name: community.docker # ansible.posix and community.general ship in the image
version: ">=4.0.0,<5.0.0"make up # the container must be running
make galaxy # installs everything declared in requirements.ymlBoth roles_path and collections_path in configs/ansible.cfg already point at /configs/.galaxy/, so installed content is found automatically.
- name: Install Docker
hosts: all
roles:
- geerlingguy.dockermake run PLAYBOOK=site.ymlTip: Inspect installed content from inside the container:
make shell ansible-galaxy list # installed roles + versions ansible-galaxy role info geerlingguy.nginx # details for a Galaxy role
# Ping all hosts to verify connectivity
docker exec -it ansible-controller ansible all -m ping
# Ping a specific group
docker exec -it ansible-controller ansible webservers -m ping
# Run a shell command on all hosts
docker exec -it ansible-controller ansible all -m shell -a "uptime"
# Check disk space
docker exec -it ansible-controller ansible all -m shell -a "df -h"
# Gather all facts from a host
docker exec -it ansible-controller ansible server1 -m setup
# Gather a specific fact
docker exec -it ansible-controller ansible all -m setup \
-a "filter=ansible_os_family"
# Copy a file to all hosts
docker exec -it ansible-controller ansible all -m copy \
-a "src=/configs/file.txt dest=/tmp/file.txt"
# Install a package (requires become)
docker exec -it ansible-controller ansible all -m apt \
-a "name=nginx state=present" --become
# Restart a service
docker exec -it ansible-controller ansible all -m service \
-a "name=nginx state=restarted" --become
# Reboot all hosts and wait for them to come back
docker exec -it ansible-controller ansible all -m reboot --becomegit clone https://github.com/allamiro/ansible-controller.git
cd ansible-controller
docker build -t ansible-controller:local -f docker/Dockerfile .# Prepare ssh/ directory first (see Quick start step 1)
docker run -d --name ansible-controller \
-p 2222:22 \
-v "$PWD/configs":/configs:rw \
-v "$PWD/playbooks":/configs/playbooks:ro \
-v "$PWD/logs":/var/log/ansible:rw \
-v "$PWD/ssh":/home/ansible/.ssh:ro \
ansible-controller:latestNote: Mount the entire
ssh/directory (not a single file). Setchmod 700 sshandchmod 600 ssh/authorized_keyson the host before starting.
A dynamic inventory script is included at configs/inventory/inventory.py. It reads hosts from configs/inventory/hosts.json when present and falls back gracefully when the file is absent.
hosts.json example:
{
"all": {
"hosts": ["192.168.1.10", "192.168.1.11"],
"vars": { "ansible_user": "ansible" }
},
"webservers": {
"hosts": ["192.168.1.10"],
"vars": {}
}
}Use it:
docker exec -it ansible-controller \
ansible-playbook -i /configs/inventory/inventory.py /configs/playbooks/site.ymlPull live inventory from your cloud provider instead of maintaining a static hosts file. Each provider needs its collection (declared in configs/requirements.yml) and its Python SDK (declared in configs/pip-requirements.txt) — both are installed automatically when the container starts, or on demand with make galaxy and make pip.
| Provider | Collection (requirements.yml) |
SDK (pip-requirements.txt) |
Inventory plugin |
|---|---|---|---|
| AWS | amazon.aws |
boto3 |
amazon.aws.aws_ec2 |
| Azure | azure.azcollection |
azure-identity, azure-mgmt-* |
azure.azcollection.azure_rm |
| GCP | google.cloud |
google-auth, requests |
google.cloud.gcp_compute |
Commented, version-pinned entries for all three providers ship in both files. In pip-requirements.txt simply uncomment the lines; in requirements.yml replace the empty collections: [] list at the bottom with a collections: block containing the entries you need (the commented example block shows the exact syntax). Example AWS setup:
# configs/requirements.yml
collections:
- name: amazon.aws
version: ">=9.0.0,<10.0.0"# configs/pip-requirements.txt
boto3>=1.34,<2
# configs/inventory/aws_ec2.yml — filename must end in aws_ec2.yml
plugin: amazon.aws.aws_ec2
regions:
- us-east-1
keyed_groups:
- key: tags.Role
prefix: rolemake up && make galaxy && make pip # galaxy/pip also wait for the startup installs
docker exec -it ansible-controller \
ansible-inventory -i /configs/inventory/aws_ec2.yml --graphProvide cloud credentials the usual way (environment variables on the container, or credential files mounted under configs/ and referenced from the inventory file).
pywinrm (with NTLM support) is baked into the image, so Windows hosts work out of the box over WinRM:
# configs/inventory/hosts.ini
[windows]
win-server1 ansible_host=192.168.1.20
[windows:vars]
ansible_connection=winrm
ansible_user=Administrator
ansible_winrm_transport=ntlm
ansible_port=5986
# the sudo become defaults in ansible.cfg don't apply to Windows
ansible_become=false
# 'ignore' is for labs only — validate certs in production
# (note: INI inventory values keep trailing text, so comments must stay on their own line)
ansible_winrm_server_cert_validation=ignoredocker exec -it ansible-controller ansible windows -m ansible.windows.win_pingThe ansible.windows collection is not baked in — declare it (pinned) in configs/requirements.yml. The Kerberos transport compiles against native libraries that don't survive container recreation, so bake it into a small derived image instead of installing at runtime:
# Dockerfile.kerberos
FROM allamiro1/ansible-controller:latest
USER root
RUN apt-get update \
&& apt-get install -y --no-install-recommends gcc python3-dev libkrb5-dev krb5-user \
&& pip3 install --no-cache-dir --break-system-packages 'pyspnego[kerberos]>=0.10,<1' \
&& apt-get purge -y --auto-remove gcc python3-dev libkrb5-dev \
&& rm -rf /var/lib/apt/lists/*docker build -f Dockerfile.kerberos -t ansible-controller:kerberos .
# then use this tag in docker-compose.yml / docker runTwo ways to supply the vault password — pick one:
Option A — password file (simplest). Drop the password in configs/.vault_pass (the path is gitignored so it can't be committed):
echo 'my-vault-password' > configs/.vault_pass
chmod 600 configs/.vault_passOption B — environment variable (no file on the host). Export ANSIBLE_VAULT_PASSWORD and uncomment the matching line in docker-compose.yml; the entrypoint writes it to a file readable only by the ansible user inside the container.
Either way the entrypoint copies the password to a file readable only by the ansible user and exports ANSIBLE_VAULT_PASSWORD_FILE to all SSH sessions — interactive logins and one-shot ssh host command runs alike (via pam_env) — so vaulted content just works:
docker exec -it ansible-controller ansible-vault encrypt_string 'secret123' --name db_password
ssh -p 2222 ansible@localhost ansible-playbook /configs/playbooks/site.yml # vault decrypts automaticallyFor docker exec (which bypasses PAM), run through bash -lc or pass --vault-password-file explicitly:
docker exec -it ansible-controller bash -lc 'ansible-playbook /configs/playbooks/site.yml'ansible-lint is baked into the image:
make lint # lints everything under playbooks/
# or lint a single file (run from the playbooks dir so config discovery works):
docker exec -it ansible-controller sh -c 'cd /configs/playbooks && ansible-lint site.yml'Customize rules with a .ansible-lint file in the playbooks/ directory — both commands run from there, which is where ansible-lint looks for its configuration.
Mitogen is baked into the image (disabled by default) with its strategy plugin already on Ansible's default search path. It multiplexes SSH connections and can cut playbook runtime substantially on large inventories. Enable it by uncommenting one line in configs/ansible.cfg:
strategy = mitogen_linearLeave it disabled if you depend on the free strategy or strategy-sensitive plugins — Mitogen replaces the linear strategy wholesale.
To allow the controller to connect passwordlessly to your managed servers, generate a key pair on the host and let the container pick it up via the volume mount.
# Generate the key pair into the ssh/ directory
ssh-keygen -t ed25519 -C "ansible-controller" -f ssh/id_ed25519 -N ""
chmod 600 ssh/id_ed25519Copy the public key to every server you want Ansible to manage:
ssh-copy-id -i ssh/id_ed25519.pub user@server1
ssh-copy-id -i ssh/id_ed25519.pub user@server2Tell Ansible to use the key by adding this to configs/ansible.cfg:
[defaults]
private_key_file = /home/ansible/.ssh/id_ed25519The private key is available inside the container at /home/ansible/.ssh/id_ed25519 via the volume mount. Restart the container after adding the key if it was already running.
To use your host SSH keys inside the container without copying them to disk, uncomment the volume and environment entries in docker-compose.yml:
volumes:
- ${SSH_AUTH_SOCK}:/run/host-services/ssh-auth.sock
environment:
- SSH_AUTH_SOCK=/run/host-services/ssh-auth.sockMake sure your key is loaded on the host first:
ssh-add ~/.ssh/id_ed25519Ansible logs are written to /var/log/ansible/ansible.log inside the container and persisted to ./logs/ansible.log on the host via the volume mount.
# Tail logs from the host
tail -f logs/ansible.log
# Or from inside the container
docker exec -it ansible-controller tail -f /var/log/ansible/ansible.logEvery push to main is automatically tagged based on conventional commit prefixes:
| Commit prefix | Version bump | Example |
|---|---|---|
fix: / perf: / refactor: |
patch | v1.0.0 → v1.0.1 |
feat: |
minor | v1.0.0 → v1.1.0 |
feat!: / BREAKING CHANGE |
major | v1.0.0 → v2.0.0 |
The new git tag triggers the publish workflow which:
- Builds and pushes
v1.2.3,v1.2,v1,latesttags to both Docker Hub and GHCR - Creates a GitHub Release with auto-generated changelog
Every published multi-arch manifest is signed with cosign using keyless GitHub OIDC — no long-lived signing keys exist. Verify a pulled image before running it:
cosign verify \
--certificate-identity-regexp 'https://github\.com/allamiro/ansible-controller/\.github/workflows/docker-publish\.yml@.*' \
--certificate-oidc-issuer https://token.actions.githubusercontent.com \
ghcr.io/allamiro/ansible-controller:latestThe same works against docker.io/allamiro1/ansible-controller. A valid signature proves the image was built and published by this repository's GitHub Actions workflow, not tampered with in transit or on the registry.
Contributions are welcome. Please open an issue before submitting a pull request so the change can be discussed first.
- Fork the repository
- Create a feature branch:
git checkout -b feat/my-feature - Commit using conventional commits:
feat:,fix:,docs:etc. - Push and open a pull request against
main
Bug reports, feature requests, and documentation improvements are all appreciated.
This project is licensed under the Apache License 2.0.
- Base image: Ubuntu 26.04 LTS — standard security support until 2031, extended further with Ubuntu Pro.
- Ansible: the image ships the current
ansible-core(via pip) plus theansible.posixandcommunity.generalcollections — not the ~280 MiBansiblecommunity bundle. Declare any additional collections or roles inconfigs/requirements.yml; they are installed automatically at container start (or on demand withmake galaxy) into the host-persistedconfigs/.galaxy/directory, no rebuild needed. - If
configs/ansible.cfgexists on the host it is used automatically; otherwise the image default applies. - The
ansibleuser (uid 1000) is the only user inside the container.PermitRootLogin nois enforced. - SSH host keys are generated on first container start (not baked into the image, so every deployment gets unique keys). Keys live in
/etc/ssh/host_keys, and the compose file persists that directory in thessh-host-keysvolume so they survive container recreation (only the keys are persisted —sshd_configandmodulikeep tracking the image). Without a volume on/etc/ssh/host_keys, recreating the container generates new keys and SSH clients will warn about a changed host key. - A
HEALTHCHECKverifies sshd is listening on port 22. Check container health withdocker ps.
