Containers don’t have to live on Kubernetes. A vast swath of production workloads still runs on container hosts: VMs with Docker or Podman, edge devices, single-tenant compute, GPU rigs, build farms, and CI runners. Even Kubernetes itself depends on a container runtime under each node. For all of these, Ansible is the canonical automation tool — the same tool you use for Linux configuration management can pull images, build images, run containers, manage Compose files, and orchestrate registries.
This lesson covers the two major container collections in ansible-galaxy: community.docker (the long-standing Docker integration, also works against the Docker API on Linux/macOS) and containers.podman (the rootless/daemonless alternative that’s become the Red Hat default and the basis for Ansible Automation Platform’s own execution environments). You’ll learn module-by-module what each collection ships, when to use Docker vs. Podman, how to template Compose files, how to build and push images from playbooks, how to use Podman’s quadlet/systemd integration for boot-time container lifecycle, and how to handle registry auth without leaking credentials.
Learning Objectives
By the end you will be able to:
- Install
community.dockerandcontainers.podmanand configure their Python dependencies. - Use
community.docker.docker_containerfor single-container lifecycle (run, stop, restart, recreate). - Use
community.docker.docker_compose_v2to drive multi-service Compose files from Ansible. - Build and push Docker images with
docker_imageanddocker_image_build(including multi-arch with buildx). - Manage Docker networks, volumes, and secrets with the corresponding modules.
- Use
containers.podman.podman_containerfor the same lifecycle on Podman hosts. - Use
containers.podman.podman_playto apply Kubernetes-format manifests on Podman (the kube-yaml interop). - Manage rootless containers and persist them across reboots via
podman_systemd_generate/ quadlet files. - Authenticate to private registries (Docker Hub, ECR, GAR, ACR, GHCR) without leaking secrets.
- Choose between Docker and Podman based on rootlessness, license, daemon model, and integration constraints.
Prerequisites
- Tier 1–3 Ansible fluency.
- Basic understanding of containers (images, layers, volumes, networks, registries).
- A test Linux host (RHEL/Ubuntu/Debian) with Docker or Podman installed — or a
kind/local VM.
Mental Model: Containers from Ansible
1. Container hosts are SSH targets — same as any other Linux host
There’s no special transport for containers. The control node SSHes to the container host, lands in /root or /home/user, and runs the Docker/Podman CLI through the appropriate Python library (docker-py for Docker, podman-py for Podman). Auth, sudo, and inventory work exactly as they do for any Linux host.
2. Two collections, two daemon models
community.docker talks to the Docker daemon (rootful, single shared daemon, runs as root by default). containers.podman talks to Podman (daemonless, can run rootless per-user, no central daemon). The collections look similar — same module names with the prefix swapped — but the underlying systems differ in security posture, root requirements, and persistence model.
3. docker_compose_v2 replaced docker_compose
The legacy community.docker.docker_compose module wrapped docker-compose v1 (the Python implementation). Modern installs use Compose v2 (the Go-based plugin: docker compose). community.docker.docker_compose_v2 is the right module for any new playbook — it shells out to docker compose and parses its JSON output. The legacy module is deprecated.
4. Podman is the right default for new Linux deployments
Red Hat defaults to Podman on RHEL 8+, and the rootless model is significantly safer than Docker’s daemon-as-root architecture. Podman also runs Pods (not just containers — a Pod is a group of containers sharing network and PID namespaces, like a K8s pod) and accepts Kubernetes-format YAML manifests via podman play kube. If you’re starting fresh on RHEL, choose Podman.
5. Container images don’t need to be pre-built — Ansible can build them
community.docker.docker_image and containers.podman.podman_image can build images from a Dockerfile in your playbook tree, push them to a registry, pull them on target hosts, and tag them. This means your image build pipeline can live alongside your config management — for small shops, the simplest CI/CD is “Ansible builds the image, Ansible pushes it, Ansible runs it on hosts.”
Setting Up the Control Node
Both collections need their respective Python clients on the control node and the equivalent CLI on the target.
# Control node Python deps
python3 -m pip install --user 'docker>=7.0.0' 'podman-compose' 'requests>=2.31'
# Install collections
ansible-galaxy collection install community.docker containers.podman
On the target hosts, install Docker or Podman (or both):
# bootstrap-docker.yml
- hosts: docker_hosts
become: true
tasks:
- name: Install Docker via Docker's repo (Ubuntu)
block:
- name: Add Docker GPG key
ansible.builtin.get_url:
url: https://download.docker.com/linux/ubuntu/gpg
dest: /etc/apt/keyrings/docker.asc
mode: '0644'
- name: Add Docker repo
ansible.builtin.apt_repository:
repo: "deb [arch=amd64 signed-by=/etc/apt/keyrings/docker.asc] https://download.docker.com/linux/ubuntu {{ ansible_distribution_release }} stable"
state: present
- name: Install Docker CE
ansible.builtin.apt:
name:
- docker-ce
- docker-ce-cli
- containerd.io
- docker-compose-plugin
state: present
update_cache: true
- name: Ensure docker.service is started
ansible.builtin.systemd:
name: docker
enabled: true
state: started
when: ansible_distribution == 'Ubuntu'
- name: Install Podman (RHEL family)
ansible.builtin.dnf:
name:
- podman
- podman-compose
- python3-podman
state: present
when: ansible_os_family == 'RedHat'
The community.docker Collection — Module-by-Module
docker_container — single-container lifecycle
The most-used module. Pull the image, create the container, set ports/volumes/env, start it.
- name: Run nginx with persistent config
community.docker.docker_container:
name: web
image: nginx:1.27-alpine
state: started
restart_policy: unless-stopped
ports:
- "8080:80"
volumes:
- /srv/www:/usr/share/nginx/html:ro
- /srv/nginx/conf.d:/etc/nginx/conf.d:ro
env:
NGINX_HOST: example.com
NGINX_PORT: "80"
log_driver: json-file
log_options:
max-size: "10m"
max-file: "3"
healthcheck:
test:
- CMD
- curl
- -f
- http://localhost/
interval: 30s
timeout: 5s
retries: 3
Key parameters:
| Parameter | Purpose |
|---|---|
state |
started, stopped, present, absent |
recreate |
Force destroy+recreate even if config matches |
comparisons |
Per-field comparison override (strict, ignore, allow_more_present) |
pull |
never / missing / always — when to pull the image |
restart_policy |
no / on-failure / always / unless-stopped |
network_mode |
bridge / host / none / <custom-network> |
comparisons: is the secret weapon for handling externally-set fields. If your monitoring sidecar adds labels, use comparisons: {labels: allow_more_present} so Ansible doesn’t fight over them.
docker_compose_v2 — multi-service Compose
Drives docker compose for multi-service applications.
- name: Deploy a Compose stack
community.docker.docker_compose_v2:
project_src: /opt/myapp
state: present
pull: always
files:
- compose.yaml
- compose.prod.yaml
env_files:
- /opt/myapp/.env
The project_src is a directory containing compose.yaml (or files passed via files:). community.docker.docker_compose_v2 is idempotent — Ansible only restarts services whose definitions changed.
docker_image — build, pull, push, tag
- name: Pull an image
community.docker.docker_image:
name: redis:7.2-alpine
source: pull
- name: Build an image from a Dockerfile in the playbook tree
community.docker.docker_image:
name: registry.example.com/myapp:{{ git_sha }}
source: build
build:
path: ./docker/myapp
dockerfile: Dockerfile
pull: true
args:
APP_VERSION: "{{ git_sha }}"
- name: Push the image
community.docker.docker_image:
name: registry.example.com/myapp:{{ git_sha }}
push: true
source: local
- name: Tag latest
community.docker.docker_image:
name: registry.example.com/myapp:{{ git_sha }}
repository: registry.example.com/myapp:latest
source: local
push: true
For multi-arch builds, use docker_image_build with buildx:
- name: Multi-arch build
community.docker.docker_image_build:
name: registry.example.com/myapp
tag: "{{ git_sha }}"
path: ./docker/myapp
platform:
- linux/amd64
- linux/arm64
push: true
docker_network and docker_volume
- name: Create a custom bridge network
community.docker.docker_network:
name: app-net
driver: bridge
ipam_config:
- subnet: 172.20.0.0/24
gateway: 172.20.0.1
- name: Create a named volume
community.docker.docker_volume:
name: db-data
driver: local
- name: Inspect existing volumes
community.docker.docker_volume_info:
name: db-data
register: vol
docker_login — registry auth
- name: Login to ECR (using ephemeral token)
community.docker.docker_login:
registry_url: 123456789012.dkr.ecr.us-east-1.amazonaws.com
username: AWS
password: "{{ ecr_token }}"
reauthorize: true
no_log: true
The no_log: true is mandatory — without it, the password lands in your playbook output.
For ECR specifically, get the token with the AWS CLI first:
- name: Get ECR auth token
ansible.builtin.command:
cmd: aws ecr get-login-password --region us-east-1
register: ecr_pwd
changed_when: false
no_log: true
- name: Docker login to ECR
community.docker.docker_login:
registry_url: "{{ aws_account_id }}.dkr.ecr.us-east-1.amazonaws.com"
username: AWS
password: "{{ ecr_pwd.stdout }}"
no_log: true
docker_swarm — Swarm cluster mode (legacy but supported)
- name: Initialize a Swarm cluster
community.docker.docker_swarm:
state: present
advertise_addr: "{{ ansible_default_ipv4.address }}"
- name: Add managers (run on second node)
community.docker.docker_swarm:
state: join
join_token: "{{ swarm_join_token }}"
advertise_addr: "{{ ansible_default_ipv4.address }}"
remote_addrs:
- "{{ swarm_leader_ip }}:2377"
Swarm is in maintenance mode (Docker pivoted to Kubernetes years ago), but the modules still work for legacy environments.
The containers.podman Collection — Module-by-Module
containers.podman mirrors the Docker collection with Podman semantics. Most modules are Podman equivalents of Docker modules.
podman_container — single-container lifecycle
- name: Run a container with Podman (rootless)
containers.podman.podman_container:
name: nginx
image: docker.io/library/nginx:1.27-alpine
state: started
ports:
- "8080:80"
volumes:
- "%h/www:/usr/share/nginx/html:Z" # Note SELinux :Z label
network: bridge
restart_policy: always
user: "{{ ansible_user_id }}" # Runs as the playbook user (rootless)
Two Podman-specific gotchas:
- SELinux labels (
:Zfor private,:zfor shared) on volumes. Without them, the container can’t read host bind-mounts on RHEL/Fedora. - Rootless networking uses slirp4netns by default, which is slower than rootful bridge. For high-throughput services, run rootful Podman or use
--network=host(with the security trade-off).
podman_pod — Pods (Podman’s K8s-pod equivalent)
- name: Create a pod with shared network
containers.podman.podman_pod:
name: web-stack
state: started
ports:
- "80:80"
- "443:443"
- name: Add nginx container to the pod
containers.podman.podman_container:
name: web-nginx
image: docker.io/library/nginx:1.27-alpine
pod: web-stack
state: started
- name: Add app container to the pod
containers.podman.podman_container:
name: web-app
image: registry.example.com/myapp:v1.2.3
pod: web-stack
state: started
Containers in the same pod share network — web-app reaches web-nginx on localhost. This is the Kubernetes-pod model on a single host.
podman_play — apply Kubernetes manifests
The killer feature: take a K8s YAML manifest and run it on Podman without a cluster.
- name: Apply a Kubernetes deployment via podman play
containers.podman.podman_play:
kube_file: /srv/podman/myapp.yaml
state: started
network: bridge
The myapp.yaml is a real K8s manifest:
apiVersion: v1
kind: Pod
metadata:
name: myapp
spec:
containers:
- name: api
image: registry.example.com/myapp-api:v1.2.3
ports:
- containerPort: 8080
- name: cache
image: docker.io/library/redis:7.2-alpine
This means you can take a manifest tested in K8s and run it on a single Podman host without changes — ideal for edge deployments where K8s is overkill.
podman_image — build, pull, push
- name: Pull an image
containers.podman.podman_image:
name: docker.io/library/nginx:1.27-alpine
- name: Build from Dockerfile (Containerfile is preferred name in Podman world)
containers.podman.podman_image:
name: registry.example.com/myapp:{{ git_sha }}
path: ./containers/myapp
build:
file: Containerfile
pull: true
- name: Push to a registry
containers.podman.podman_image:
name: registry.example.com/myapp:{{ git_sha }}
push: true
push_args:
tls_verify: true
podman_systemd_generate — boot-time container lifecycle
The Podman + systemd integration is the standard way to make rootless containers survive reboots:
- name: Generate systemd unit for a container
containers.podman.podman_systemd_generate:
name: web-nginx
new: true
dest: /home/{{ ansible_user_id }}/.config/systemd/user/
- name: Enable and start via systemd --user
ansible.builtin.systemd:
name: container-web-nginx.service
enabled: true
state: started
scope: user
Modern Podman (4.4+) also supports Quadlet files — declarative .container, .pod, .network, .volume files placed in ~/.config/containers/systemd/ that systemd reads natively:
- name: Deploy a Quadlet unit
ansible.builtin.copy:
dest: /home/{{ ansible_user_id }}/.config/containers/systemd/web.container
content: |
[Container]
Image=docker.io/library/nginx:1.27-alpine
PublishPort=8080:80
Volume=/srv/www:/usr/share/nginx/html:Z
[Service]
Restart=always
[Install]
WantedBy=default.target
- name: Reload user systemd
ansible.builtin.systemd:
daemon_reload: true
scope: user
Quadlet is the modern default — declarative, version-controllable, and integrates with systemd dependency ordering.
Hands-on Free Lab: Multi-Container App with Compose and Podman
Free, runs on any Linux VM. Deploys a Postgres + API stack two ways: with Docker+Compose, and with Podman+Pods.
# On a Linux VM with Docker installed
mkdir -p ~/ansible-containers-lab && cd ~/ansible-containers-lab
mkdir -p compose podman
# Inventory
cat > inventory.yml <<'EOF'
all:
hosts:
localhost:
ansible_connection: local
EOF
# Compose project
cat > compose/compose.yaml <<'EOF'
services:
db:
image: postgres:16-alpine
environment:
POSTGRES_USER: app
POSTGRES_PASSWORD: secret
POSTGRES_DB: app
volumes:
- db-data:/var/lib/postgresql/data
networks:
- app-net
api:
image: hashicorp/http-echo:1.0
command: ["-text=hello from compose"]
ports:
- "8081:5678"
networks:
- app-net
volumes:
db-data:
networks:
app-net:
EOF
# Docker playbook
cat > deploy-compose.yml <<'EOF'
---
- hosts: localhost
gather_facts: false
tasks:
- name: Ensure compose stack is up
community.docker.docker_compose_v2:
project_src: ./compose
state: present
pull: always
EOF
# Podman playbook
cat > deploy-podman.yml <<'EOF'
---
- hosts: localhost
gather_facts: false
tasks:
- name: Create a pod
containers.podman.podman_pod:
name: app-stack
state: started
ports:
- "8082:5678"
- name: Run db in the pod
containers.podman.podman_container:
name: db
image: docker.io/library/postgres:16-alpine
pod: app-stack
state: started
env:
POSTGRES_USER: app
POSTGRES_PASSWORD: secret
POSTGRES_DB: app
- name: Run api in the pod
containers.podman.podman_container:
name: api
image: docker.io/hashicorp/http-echo:1.0
pod: app-stack
state: started
command:
- -text=hello from podman pod
EOF
# Run both
ansible-playbook -i inventory.yml deploy-compose.yml
ansible-playbook -i inventory.yml deploy-podman.yml
# Verify
curl -s http://localhost:8081/ # Compose
curl -s http://localhost:8082/ # Podman pod
# Cleanup
ansible-playbook -i inventory.yml deploy-compose.yml --extra-vars "state=absent"
podman pod rm -f app-stack
Common Mistakes & Troubleshooting
1. “Permission denied” mounting volumes on RHEL/Fedora with Podman
SELinux blocks bind-mounts without the :Z (private) or :z (shared) label. Always add :Z to bind mounts unless multiple containers need to share the same path.
2. docker_container keeps restarting due to image hash mismatch
You ran a pull: always and the image got a new digest. The container’s image-digest comparison triggers recreate. Use pull: missing for stable runs, pull: always only on intentional updates.
3. docker_compose_v2 reports “ContainerConfig” KeyError
The community.docker Python lib is too old, or you have legacy docker-compose v1 installed. Upgrade pip install -U 'docker>=7.0.0' and remove docker-compose v1 from PATH.
4. Rootless Podman can’t bind to ports < 1024
Linux blocks unprivileged users from binding low ports by default. Either use a higher port and a reverse proxy on port 80, or set sysctl net.ipv4.ip_unprivileged_port_start=80 (with care — it lowers the privilege boundary).
5. ECR push fails with “no basic auth credentials”
The Docker login token expired (12-hour lifetime). Re-run aws ecr get-login-password and docker_login before push tasks.
6. podman_play ignores imagePullPolicy
Podman doesn’t fully implement K8s pod spec semantics. Some fields (initContainers, livenessProbe) work; others (PVCs, Services) don’t. podman play is a single-host runtime, not a K8s API.
7. Container hostnames don’t resolve between containers in same Compose file
Compose creates a default network where service names are DNS names — db resolves from api. If it doesn’t work, you set network_mode: host (which removes the bridge), or you used network: external: true and forgot to attach.
Best Practices
- Pin image tags by digest in production:
nginx:1.27-alpine@sha256:abc.... Tags are mutable; digests are not. - Use
pull: missingfor steady-state plays,pull: alwaysonly when intentionally updating. - Set restart policies explicitly:
restart_policy: unless-stoppedis the safe default.alwaysbrings back stopped containers (which you may have stopped intentionally). - Set log driver options. Default
json-filewith no rotation fills disks. Usemax-size: 10m, max-file: 3. - Use
comparisons:to coexist with other tools. If sidecars or operators add labels, setlabels: allow_more_present. - Prefer Podman + Quadlet for new RHEL deployments. Daemonless, rootless, systemd-native.
- Use
no_log: trueon every registry login task. Without it, passwords print to stdout. - Keep Compose files in source control. Reference them via
files:indocker_compose_v2, not inline. - Build images in CI, not in production playbooks. Dev playbooks can build, but prod plays should only pull pre-built images.
Security Notes
- Rootless Podman beats rootful Docker on the security axis. A compromised container in rootless Podman lands in the user’s namespace, not as host root.
- Never run
--privilegedunless you have a specific need (typically for a kernel-module-loading container). Use specific Linux capabilities (cap_add: [NET_ADMIN]) instead. - Pin base image digests in your Dockerfile:
FROM ubuntu:22.04@sha256:.... Defeats supply-chain pivoting. - Scan images with Trivy, Grype, or Clair before deploy.
community.dockerplays well with Trivy in a pipeline (ansible.builtin.command: trivy image). - Don’t bake secrets into images. Use
--secretmounts (BuildKit), runtime env vars sourced from Vault, or external secret stores. - Use a private registry with auth and TLS. Even an internal Harbor or Nexus is preferable to public Docker Hub for private images.
Q&A — 13 Questions
Q1. Should I use Docker or Podman for new deployments? Podman, on RHEL/Fedora/CentOS Stream. It’s the Red Hat default, runs rootless, has no daemon, and integrates with systemd. Docker remains a strong choice on Ubuntu/Debian where Podman packaging is less mature.
Q2. What’s the difference between docker_compose and docker_compose_v2?
docker_compose is the legacy module that wraps Docker Compose v1 (Python implementation, docker-compose binary). docker_compose_v2 wraps Compose v2 (Go plugin, docker compose). Use v2 — v1 is end-of-life.
Q3. How does podman_play differ from real Kubernetes?
podman play runs a single Pod on a single host using Podman’s runtime. There’s no scheduler, no Service IP, no PersistentVolume controller. It’s K8s-YAML-as-config, not K8s-as-platform.
Q4. Why use Quadlet over podman_systemd_generate?
Quadlet is declarative — you write a .container file and systemd reads it. systemd_generate produces stateful systemd unit files that don’t refresh when you change the container definition. Quadlet auto-regenerates the unit on systemctl daemon-reload.
Q5. Can I run Docker and Podman on the same host? Technically yes (different sockets), but it’s confusing for operators. Pick one.
Q6. How do I build multi-arch images?
community.docker.docker_image_build with platform: [linux/amd64, linux/arm64] uses Docker buildx. For Podman, use podman_image with arch: parameters or shell out to buildah.
Q7. How do I manage Docker secrets?
community.docker.docker_secret for Swarm secrets. For non-Swarm, mount a Vault-decrypted file as a volume. Don’t pass secrets via env vars (they show in docker inspect).
Q8. Can Ansible run a private registry?
Yes — Harbor, Distribution Registry, or Nexus run as containers. Deploy with docker_container or podman_container, mount persistent storage, and configure TLS.
Q9. Why does docker_container keep showing changed: true?
Image digest mismatch (someone re-pushed the same tag), or label/env added by another tool, or restart_policy defaults differ from current state. Use comparisons: allow_more_present for fields you don’t own.
Q10. How do I run containers as a non-root user inside the container?
user: 1000:1000 in docker_container/podman_container. The container’s process runs as UID 1000 inside; map UIDs with userns_mode: keep-id for Podman to keep host-side ownership tidy.
Q11. What’s the right way to update a running stack?
For Compose: bump image versions in compose.yaml, rerun docker_compose_v2: state: present — only changed services restart. For pods: update image in podman_container, rerun the play; the module recreates only that container.
Q12. How do I delete every dangling image?
community.docker.docker_prune: images: true. Caution: also clears images you intend to keep. Filter with images_filters: until: 24h to only prune old ones.
Q13. Can Ansible build OCI images without Docker installed?
Yes — containers.podman.podman_image with path: and build: uses buildah, which doesn’t need a daemon. Useful in containerized CI runners that can’t run Docker-in-Docker.
Quick Check
- What’s the modern Compose module name in
community.docker? - What does
:Zmean on a Podman volume mount? - What’s a Pod in Podman?
- Where do Quadlet files live for a user?
- How do you log into ECR with
docker_login? - What’s the difference between
pull: alwaysandpull: missing? - What does
comparisons: allow_more_presentdo? - Why is
no_log: truemandatory on registry login tasks?
Exercise
Build a complete role containerized_web_stack that:
- Detects whether the target has Docker or Podman installed (use
ansible_facts.packages). - Conditionally branches: if Docker, use
community.docker; if Podman, usecontainers.podman. - Pulls a templated Compose file (Docker) or generates Quadlet units (Podman) from the same Jinja template.
- Configures log rotation on the container daemon (
/etc/docker/daemon.jsonfor Docker,containers.conffor Podman). - Sets up an Nginx reverse proxy in front of the app stack, terminating TLS with a Let’s Encrypt cert via
certbot(which runs as a separate container). - Includes a
validate.ymlthat confirms the stack responds with HTTP 200 onhttps://....
Test on a Docker host (Ubuntu) and a Podman host (RHEL) — confirm both produce identical behavior.
Cert Mapping
- EX374 — Container automation is one of the seven domain blocks. Expect a Compose-style multi-container deployment under time pressure.
- EX188 / EX180 — Red Hat container certs assume Podman fluency; this lesson’s Podman half maps directly.
- CKAD — The K8s manifest portability via
podman playis a useful bridge.
Glossary
- Daemon — A long-running process. Docker has one (dockerd, runs as root); Podman doesn’t.
- Rootless — Containers run as a non-root user with their own user namespace. Podman default; Docker requires special setup.
- Pod — A group of containers sharing network and PID namespaces. Native to Podman; the K8s pod abstraction.
- Compose — A YAML format for defining multi-service container applications. Docker Compose v2 is the current implementation.
- Quadlet — Podman’s declarative systemd-integrated container unit files (
.container,.pod,.volume,.network). - OCI — Open Container Initiative, the standard image and runtime format. Both Docker and Podman produce/consume OCI images.
- Buildah — A daemonless image build tool. Backs
podman buildand runs standalone. - Containerfile — Podman’s preferred name for a Dockerfile (same syntax).
Next Steps
You can now drive container hosts from Ansible — Docker, Podman, or both — including image builds, registry pushes, Compose stacks, and systemd-integrated rootless containers. The next lesson covers Ansible for databases: PostgreSQL, MySQL, and MongoDB lifecycle, replication setup, backups, schema migrations, and the patterns that let Ansible manage stateful services as carefully as it manages stateless ones.