What Happened When I Put the Ansible Controller in a Container: Building a Portable Automation Node
Translated from the original Korean post. 한국어 원문 보기 →
The control node is different everywhere
I've been running Ansible across a few different environments lately, and something kept nagging at me. Same playbook, slightly different results depending on where I ran it from.
Managing infrastructure with Ansible sounds trivial when you write it down. Install it, define an inventory, write a playbook, SSH into the remotes. No agents to deploy, which makes it look even cleaner.
In practice, the control node itself becomes the source of drift. One engineer runs it from a laptop. Someone else runs it from a shared Linux box. CI runs it on an ephemeral runner. Throw in a jump box and a lab server and the picture gets messier. Those nodes disagree on the Ansible version, then on the Python package set, then on SSH config, then on directory layout.
Ansible is agentless, but it leans hard on the control environment. The machine you run from needs the right version, the Python dependencies, the SSH client config, the inventory, the roles, the collections, the logging paths. Home lab, enterprise lab, CI/CD — over time, reproducing that control node gets harder and harder.
Separate cause from effect and the root is this: you carefully designed your automation logic to be idempotent, then never standardized the runtime that executes it. The code is version controlled. The controller is hand-crafted and left that way.
Reproducibility in automation breaks at the runtime layer before it ever breaks in the logic.
So I started using a containerized Ansible controller image.
docker.io/allamiro1/ansible-controller
The goal is simple. A control node that runs identically anywhere Docker runs, with config, inventory, SSH access, and logs kept outside the container.

Why put the control node in a container
The usual move is to install it straight on Linux.
sudo apt install ansible
That works. But now your automation environment is tied to that host, and hosts change. Packages get upgraded, Python dependencies shift, someone edits the SSH config, logs scatter locally, and every user ends up with a slightly different setup.
A containerized controller makes the execution environment predictable. The host supplies files. The container supplies tools. The remote systems receive automation. Three lines, three roles. That separation is the whole pattern.
The image ships with the Ansible CLI preinstalled and an OpenSSH server so you can reach the controller remotely. It runs as a non-root ansible user with passwordless sudo scoped to inside the container. /configs gets mounted from the host, logs land in /var/log/ansible. It builds for both linux/amd64 and linux/arm64.
That last one matters more than you'd expect. Work on an Apple Silicon MacBook, move to an amd64 server, and the same image just runs. That's why this pattern earns its keep in labs, training environments, CI/CD testing, and portable infrastructure automation.
In infrastructure terms, the container is a portable execution runtime and the host directories are externalized state. It's the 12-factor split between application and data, applied to an automation controller.
Start with the directories
The pattern really lives in the directory structure. If you want to treat the container as disposable, everything that actually matters has to stay on the host.
Here's the project layout I'd recommend.
ansible-controller-project/
├── configs/
│ ├── ansible.cfg
│ ├── inventory/
│ │ └── hosts.ini
│ ├── playbooks/
│ │ └── site.yml
│ ├── roles/
│ └── group_vars/
├── logs/
│ └── ansible.log
└── ssh/
└── authorized_keys
What each directory is for:
| 디렉터리 | 담는 것 | 컨테이너 내부 경로 |
|---|---|---|
configs/ |
Ansible config, inventory, playbooks, roles, variables | /configs |
logs/ |
Ansible logs written from inside the container | /var/log/ansible |
ssh/ |
Public key for the ansible user when SSHing into the controller |
/home/ansible/.ssh/authorized_keys |
With this layout you can destroy and recreate the container without losing a single automation file. Controller: ephemeral. Automation assets: persistent. Clean line between the two.
Create the directories first.
mkdir -p configs/inventory
mkdir -p configs/playbooks
mkdir -p configs/roles
mkdir -p configs/group_vars
mkdir -p logs
mkdir -p ssh
Then copy in a public key so you can SSH into the container.
cp ~/.ssh/id_rsa.pub ssh/authorized_keys
Adjust for whatever your key is named. For ed25519:
cp ~/.ssh/id_ed25519.pub ssh/authorized_keys
One thing worth being clear about: this authorized_keys is not the key used to reach your managed nodes. It's the key for SSHing into the controller container itself. Mix up those two layers and debugging gets tangled later. The door into the controller and the door out of it are separate doors.
Writing ansible.cfg and the inventory
Structure done, now fill in the config files. Start with ansible.cfg.
cat > configs/ansible.cfg << 'EOF'
[defaults]
inventory = /configs/inventory/hosts.ini
retry_files_enabled = False
host_key_checking = False
log_path = /var/log/ansible/ansible.log
[privilege_escalation]
become = True
become_method = sudo
become_user = root
[ssh_connection]
pipelining = True
EOF
Four things this tells Ansible: read the inventory from /configs/inventory/hosts.ini, write logs to /var/log/ansible/ansible.log, escalate to root via sudo, and turn on SSH pipelining for speed.
host_key_checking = False deserves its own note. In a lab, turning off host key verification is convenient — when hosts are constantly being created and destroyed, key verification just gets in your way. In real production you should turn it back on and manage known_hosts properly. Settings disabled for convenience have a way of riding along into production. Happens more than you'd think.
Keep the inventory as simple as possible to start.
cat > configs/inventory/hosts.ini << 'EOF'
[all]
server1 ansible_host=192.0.2.10 ansible_user=admin
EOF
For something closer to a real environment, group hosts by role.
[web]
web1 ansible_host=10.10.10.11 ansible_user=admin
web2 ansible_host=10.10.10.12 ansible_user=admin
[database]
db1 ansible_host=10.10.20.11 ansible_user=admin
[linux:children]
web
database
Grouping like [linux:children] pays off later when you're scoping targets in a playbook. Web servers only, or all Linux hosts — you've already sorted that out at the inventory level.
First playbook, and starting the container
Write a test playbook to confirm connectivity works.
cat > configs/playbooks/site.yml << 'EOF'
---
- name: Test Ansible controller
hosts: all
become: true
tasks:
- name: Check connectivity
ansible.builtin.ping:
- name: Get hostname
ansible.builtin.command: hostname
register: hostname_output
changed_when: false
- name: Show hostname
ansible.builtin.debug:
var: hostname_output.stdout
EOF
It tests the connection and prints the hostname of every managed node. The changed_when: false is there so a read-only command doesn't get reported as 'changed' in the idempotency summary. Small thing, but it keeps playbook output honest.
Now start the container.
docker run -d --name ansible-ctrl \
-p 2222:22 \
-v "$PWD/configs":/configs:rw \
-v "$PWD/logs":/var/log/ansible:rw \
-v "$PWD/ssh/authorized_keys":/home/ansible/.ssh/authorized_keys:ro \
allamiro1/ansible-controller:latest
The mount points are basically the entire pattern.
| 호스트 경로 | 컨테이너 경로 | 모드 |
|---|---|---|
./configs |
/configs |
rw |
./logs |
/var/log/ansible |
rw |
./ssh/authorized_keys |
/home/ansible/.ssh/authorized_keys |
ro |
Note that the public key is mounted ro. The container has no business modifying the auth key itself. SSH is exposed on host port 2222.
Connecting to the controller and running things
Once it's up, SSH in.
ssh -p 2222 ansible@localhost
Then test Ansible right away.
ansible all -m ping
And run the playbook.
ansible-playbook /configs/playbooks/site.yml
Since the config files are mounted from the host, Ansible picks up /configs/ansible.cfg and /configs/inventory/hosts.ini on its own. No path flags needed.
SSHing in every time gets old, though — especially inside a script or pipeline, where an interactive session is just in the way. Use docker exec to fire commands directly.
docker exec -it ansible-ctrl ansible all -m ping
Same for playbooks.
docker exec -it ansible-ctrl ansible-playbook /configs/playbooks/site.yml
No interactive SSH session required, so it drops into a CI/CD pipeline as-is. Humans go in over SSH, automation goes in over exec. The two access paths split naturally.

Reading logs, and Docker Compose
Ansible logs land at /var/log/ansible/ansible.log inside the container. That path is mounted to the host, so read it right from the host.
cat logs/ansible.log
To follow along live, tail it.
tail -f logs/ansible.log
When you're testing a playbook or an automation run blows up, having the log sitting on the host makes debugging much easier. The container can die and the log survives. That's the separation of runtime and record paying off again.
If you're going to use this repeatedly, Compose beats a long docker run line. Write docker-compose.yml like this:
services:
ansible-controller:
image: allamiro1/ansible-controller:latest
container_name: ansible-ctrl
ports:
- "2222:22"
volumes:
- ./configs:/configs:rw
- ./logs:/var/log/ansible:rw
- ./ssh/authorized_keys:/home/ansible/.ssh/authorized_keys:ro
restart: unless-stopped
One line to start it.
docker compose up -d
Drop into a shell inside the container:
docker exec -it ansible-ctrl bash
Running Ansible is the same as before.
ansible all -m ping
Tear it down like this.
docker compose down
With restart: unless-stopped, the controller comes back on its own after a host reboot. Handy when you're running an always-on automation host in jump-server form.
Where this pattern actually pays
This setup earns its place when you need the control node to stay consistent across multiple systems. Home labs and training labs, CI/CD execution environments, throwaway automation environments, always-on automation hosts in jump-server style, and pre-production test environments. Different in character, same underlying condition: several people need to look at the same controller.
Multiple engineers share one container image and mount only their own inventory, variables, playbooks, and SSH keys. Unify the tooling, separate the data.
One scene I saw a lot on past projects: "why does this work on my laptop but not in CI?" Dig in and it was almost always an Ansible version or a collection difference. This pattern deletes that variable outright. You keep automation portable without forcing Ansible onto every host.
Honestly, installing straight on the host is what my hands are used to. When something breaks, going straight to the logs and picking apart packages is faster. But the controller my habits built turned out to be one nobody else could reproduce. Change seats and the same tool looks different. From the developer seat it's "the playbook runs, we're fine." From the operations seat the more urgent question is "can anyone else run this the same way?" Containerizing the controller is the answer that came out of the second seat.
Security is on you
Don't let convenience carry you past this part. This container should be treated as an automation control node. It has access to SSH keys, inventories, privileged accounts, and playbooks that can change remote systems. Spinning it up casually doesn't mean you get to handle it casually.
To run it more safely:
- Use read-only mounts where you can
- Don't mount private keys unless you genuinely have to
- Don't put secrets in the inventory as plaintext — sensitive values go in Ansible Vault
- Turn host key verification on in production
- Restrict access to the SSH port (
2222) - Keep the image current and review vulnerability scan results
That last one isn't boilerplate advice. Docker Hub vulnerability scans have flagged a number of vulnerabilities in some image tags. Clear those out before you put this image anywhere sensitive or in production.
Hardening isn't complicated either. Slim the base image, strip unused packages, rebuild on a schedule, add an image scanning step to CI/CD, and publish a hardened variant separately if you need one.
The controller container is where privilege collects. It holds the ability to change remote infrastructure in one place, which also makes it a condensed attack surface. Portability and control pull in opposite directions, and where you put the weight depends on the environment. Lab, lean toward convenience. Production, lean toward control. Not much else to it.

A small step toward tidier automation
Putting the control node in a container is one way to make an automation workflow more portable and more reproducible. Pull config, inventory, SSH keys, and logs out of the image, and the controller stays disposable while the automation content stays a version-controlled asset.
The allamiro1/ansible-controller image isn't anything grand. It packages a toolchain, mounts your config, runs your automation, and leaves the host environment alone. That's the whole pattern it hands you.
Tidiness in infrastructure usually starts with small separations like this one. What you let evaporate, what you keep, and where exactly you draw that line. The controller container is one attempt at drawing it. Whether it's the best line, I'm still not sure.
Was this post helpful?
One click helps me write the next one