In a nutshell
Imagine you look after twenty Linux servers and each one runs a couple of containers — a web app here, a database there, a Redis cache somewhere else. Without automation you would SSH into every box and type docker run … or podman run … by hand, hoping you got the same flags right each time. Ansible turns that into a single declarative playbook: you describe the container you want (“nginx, port 8080, this volume, restart unless I stop it”) and Ansible makes every host match that description — creating the container if it is missing, leaving it alone if it already matches, and fixing it if it drifted.
Think of Ansible as a multilingual foreman. It walks up to each machine over SSH and speaks the local container dialect — Docker on some hosts, Podman on others — using the same job order for both. You write the order once; the foreman translates. community.docker is the foreman’s Docker vocabulary; containers.podman is its Podman vocabulary. The module names even rhyme (docker_container ↔ podman_container), so once you learn one dialect the other is mostly a find-and-replace.
Why should a beginner care? Containers are everywhere, but Kubernetes is overkill for most of them. A single VM running three Podman containers, kept alive by systemd and deployed by an Ansible playbook, is a perfectly good production setup — cheaper, simpler, and easier to reason about than a cluster. This lesson teaches you exactly that pattern, plus how to build and push your own images and log in to private registries without ever leaking a password.
Level: Intermediate → Advanced · Time: ~45 min · You’ll need: basic container vocabulary (image, volume, port, registry) and Tier 1–3 Ansible fluency.
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.
Templating Compose Files from Ansible
A hard-coded compose.yaml checked into git is fine until you need the same stack on staging and prod with different image tags, ports, or replica counts. The Ansible-native answer is to keep a compose.yaml.j2 template and render it per-host (or per-environment) with ansible.builtin.template, then hand the rendered file to docker_compose_v2. This is the “templating Compose” pattern that separates structure (the template, in source control) from values (in group_vars/, with secrets in Vault).
Template templates/compose.yaml.j2:
services:
api:
image: {{ api_image }}:{{ api_tag }}
ports:
- "{{ api_host_port }}:8080"
environment:
LOG_LEVEL: "{{ api_log_level | default('info') }}"
DB_HOST: db
depends_on:
- db
networks: [app-net]
db:
image: postgres:16-alpine
environment:
POSTGRES_USER: "{{ db_user }}"
POSTGRES_PASSWORD: "{{ vault_db_password }}" # from Vault — see below
POSTGRES_DB: "{{ db_name }}"
volumes:
- db-data:/var/lib/postgresql/data
networks: [app-net]
volumes:
db-data:
networks:
app-net:
Play:
- name: Render and deploy the Compose stack
hosts: app_hosts
become: true
vars:
api_image: registry.example.com/myapp
api_tag: "{{ git_sha | default('v1.2.3') }}"
api_host_port: 8080
db_user: app
db_name: app
tasks:
- name: Ensure project dir exists
ansible.builtin.file:
path: /opt/myapp
state: directory
mode: '0750'
- name: Render compose.yaml from template
ansible.builtin.template:
src: compose.yaml.j2
dest: /opt/myapp/compose.yaml
mode: '0640'
register: compose_render
- name: Deploy the stack
community.docker.docker_compose_v2:
project_src: /opt/myapp
state: present
pull: always
Two idempotency wins stack up here:
ansible.builtin.templateis idempotent — it only rewritescompose.yamlwhen the rendered content actually changes, and reportschangedaccordingly (you can gate a handler oncompose_render.changed).docker_compose_v2then diffs the running stack against the file and restarts only the services whose definition moved. Bumpapi_tagand nothing butapicycles.
Keep the template in source control; keep the values in group_vars/ (and secrets in Vault). One template, many environments — that is the whole reason to drive Compose from Ansible instead of hand-editing YAML on each box.
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.
Registry Authentication Without Leaking Credentials
Every docker_login / podman_login needs a password, and the cardinal sin is putting that password in plaintext in a playbook or committing it to git. Ansible’s built-in answer is Ansible Vault: encrypt the secret at rest, decrypt it in memory at run time, and mark the task no_log: true so the value never reaches stdout or the log.
Two separate concerns that beginners routinely confuse:
- Vault encrypts the secret at rest — on disk, in git. It does nothing for stdout.
no_log: truehides the secret in transit — task output,-vverbosity, callback logs. It does nothing for disk.
You need both, always.
Step 1 — put the secret in an encrypted var. The cleanest pattern is an encrypted group_vars/all/vault.yml:
ansible-vault create group_vars/all/vault.yml
# opens $EDITOR; add:
# vault_registry_password: "S3cr3t-Pull-Token"
Or encrypt a single string inline (paste the output straight into a vars file):
ansible-vault encrypt_string 'S3cr3t-Pull-Token' --name 'vault_registry_password'
Step 2 — reference the variable (never the raw value) and log in with no_log:
- name: Log in to the private registry (Docker)
community.docker.docker_login:
registry_url: registry.example.com
username: "{{ registry_user }}"
password: "{{ vault_registry_password }}" # decrypted from Vault at runtime
no_log: true
- name: Log in to the private registry (Podman)
containers.podman.podman_login:
registry: registry.example.com
username: "{{ registry_user }}"
password: "{{ vault_registry_password }}"
no_log: true
Step 3 — supply the vault password at run time (never inline in the playbook):
ansible-playbook deploy.yml --ask-vault-pass
# or in CI, from a mode-0600 file the runner injects:
ansible-playbook deploy.yml --vault-password-file ~/.vault_pass
Cloud registries mint short-lived tokens rather than static passwords — fetch the token at run time and store nothing at all, which is even better than Vault because there is no durable secret to leak:
- name: Get a fresh ECR token (12-hour lifetime)
ansible.builtin.command: aws ecr get-login-password --region us-east-1
register: ecr_pwd
changed_when: false
no_log: true
- name: Log in to ECR with the ephemeral token
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
The same pattern covers GCP Artifact Registry (gcloud auth print-access-token, username oauth2accesstoken) and Azure ACR (az acr login --expose-token). For the full Vault workflow — vault IDs, multiple vaults, and re-keying — see the Ansible Vault lesson.
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
Going deeper
This section is for the reader who already runs containers from Ansible and wants to know why the modules behave the way they do — the parts that separate a play that works on your laptop from one that survives a fleet, a reboot, and an auditor.
How docker_container decides “changed” — the comparisons engine
Under the hood, docker_container does not just ask “is a container named X running?” It inspects the running container, builds the container spec your task implies, and compares them field by field. If any owned field differs, it recreates the container (stop → remove → create → start). This is why a container can report changed: true even though it is already up: some field drifted.
The comparisons parameter is your control surface over that engine. Each field takes one of three modes:
| Mode | Behaviour |
|---|---|
strict |
Value must match exactly; extra list items trigger a recreate |
ignore |
Never compare this field — Ansible won’t fight over it |
allow_more_present |
Your values must be present; extra ones set by other tools are tolerated |
- name: Run a container that coexists with a sidecar-injected label
community.docker.docker_container:
name: web
image: nginx:1.27-alpine
labels:
app: web
comparisons:
labels: allow_more_present # monitoring adds its own labels — don't fight
env: strict # but env must match exactly
'*': strict # default for everything else
The special '*' key sets the default for all unlisted fields. The single most common “why does this recreate on every run?” bug is a monitoring agent, a service mesh, or Docker itself injecting a field you don’t own — allow_more_present (or ignore) on that one field fixes it.
Image-digest drift is the other recreate trigger: if you use a mutable tag (:latest, :1.27-alpine) with pull: always and someone re-pushes that tag, the digest changes and the container recreates. Pin by digest (nginx:1.27-alpine@sha256:…) to make the comparison stable.
Check mode and diff — previewing container changes
Container modules are not read-only, so you want a dry run before touching production. community.docker.docker_container, docker_image, docker_network, docker_volume, and docker_compose_v2 support check mode:
ansible-playbook deploy.yml --check --diff
--check reports what would change without doing it; --diff shows the field-level delta the comparisons engine computed. Combine them to answer “will this run recreate my database container?” before you find out the hard way. A few caveats:
- Build and push are side-effectful.
docker_image: source: buildandpush: truecan’t be fully simulated; in check mode they report the intended action, and a build that depends on a not-yet-built base may report differently than a real run would. changed_when: falseon wrappercommand:tasks (like fetching a registry token) keeps them from polluting the changed count — they’re reads, not changes.- The dedicated check-mode & diff lesson goes deeper on writing check-mode-safe tasks.
Rootless Podman internals
“Rootless” is not a flag — it is a whole subsystem. When user deploy runs podman run, no root daemon is involved; Podman forks the container process inside a user namespace where deploy is mapped to UID 0 inside the container but remains an unprivileged user on the host. The mapping comes from /etc/subuid and /etc/subgid:
deploy:100000:65536
…meaning “UID 0 in deploy’s containers maps to host UID 100000, and it owns a 65536-UID range.” Three consequences worth knowing:
- Bind-mount ownership looks weird. A file the container writes as “root” (UID 0) shows up on the host as UID 100000.
userns_mode: keep-id(Podman) maps the container user back to your real host UID so bind-mount ownership stays sane. - Rootless containers die at logout unless you enable lingering. By default a user’s systemd session ends when they log out, killing their containers.
loginctl enable-linger deploykeeps the user manager running from boot — the missing ingredient behind “my rootless container didn’t come back after reboot.” - Rootless networking is slower. Older Podman uses
slirp4netns; Podman 4.4+/5.0 default topasta, faster but still a userspace network stack. For throughput-critical services, run rootful, use--network=host, or accept the overhead.
Quadlet under the hood
A Quadlet file is not a systemd unit — it is input to a systemd generator (podman-system-generator) that runs on every daemon-reload and emits a real .service unit from your .container/.pod/.network/.volume/.kube file. That is exactly why Quadlet is declarative and podman_systemd_generate is not: generate produces a frozen .service that goes stale the moment the container definition changes; Quadlet keeps the .container file as the source of truth and regenerates the unit to match on every reload. Quadlet is the recommended path from Podman 4.4 onward for anything you’d previously have used generate for.
The dependency and transport difference
A subtle but exam-relevant distinction: community.docker talks to the Docker API via the Python docker SDK (the docker PyPI package, formerly docker-py) — that SDK must be present on whatever host runs the module (usually the target, or the control node if you set docker_host). Most containers.podman modules shell out to the podman CLI on the target instead of using a Python SDK — so their real dependency is the podman binary, not podman-py. This is why a Podman play runs happily on a minimal execution environment that only ships the podman command, and why Docker tasks fail with Failed to import the required Python library (docker) when the SDK is missing.
Performance and scale
Driving one container host is trivial; driving three hundred is where tuning matters:
forks(inansible.cfgor-f) controls how many hosts you configure in parallel — raise it well above the default of 5 for large container fleets.async+poll: 0fires long image pulls or builds in the background so a slow registry doesn’t serialize the whole run.serial:/ rolling updates roll a stack across the fleet a batch at a time so you never take the whole service down at once — pair with a health check between batches.throttle:caps concurrency on a single task (e.g. don’t hammer a fragile registry with 300 simultaneous pulls).
See the performance-tuning lesson for the full toolkit.
Security hardening beyond the basics
- Drop to a non-root user inside the container with
user: "1000:1000", and prefer rootless Podman so even container-root is unprivileged on the host. - Never
privileged: trueas a shortcut — grant the single capability you need (capabilities:for Docker,cap_add:for Podman, e.g.NET_ADMIN) instead. - Read-only root filesystem (
read_only: trueplus atmpfsfor the paths that must be writable) shrinks the blast radius of a compromised process. no-new-privileges(viasecurity_opts: ["no-new-privileges"]) stops setuid binaries inside the container from escalating.- Verify image signatures (a Podman
policy.jsonrequiring signatures, or cosign in your pipeline) so a tampered image never runs.
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.
Common beginner mistakes
These are the misconceptions that trip people up — distinct from the symptom→fix table above. Each is a wrong mental model and the right one to replace it with.
1. “Ansible needs a special ‘container’ connection to manage containers.”
Because Kubernetes has its own API, people assume containers need a special transport too. They don’t. A container host is just a Linux box: Ansible SSHes in exactly as it would for any server and runs the Docker API / Podman CLI there. There is no container connection plugin — inventory, become, and SSH work unchanged.
2. “state: started re-pulls and recreates the container every run.”
state governs the run state (running vs stopped vs absent), not the image. Whether Ansible pulls is controlled by pull: (missing/always/never); whether it recreates is controlled by the comparisons engine. A matching, running container with pull: missing is a no-op — that’s idempotency working, not Ansible being lazy.
3. “Podman is just Docker with the commands renamed.” The CLIs rhyme, but the models differ where it counts. Docker runs a root daemon that owns every container; Podman is daemonless and can run rootless, forking each container under your user namespace. That changes security posture, how containers survive reboot (systemd/Quadlet vs the daemon), and how bind-mount ownership works. Same verbs, different machine underneath.
4. “no_log: true protects my secret, so I don’t need Vault” (and its mirror, “Vault means I don’t need no_log”).
They solve different halves. no_log keeps the secret out of stdout and logs; Vault keeps it encrypted on disk and in git. A plaintext password with no_log is still sitting in your repo; a Vaulted password without no_log still prints when the task runs. Use both.
5. “podman play kube gives me Kubernetes on one host.”
It gives you the YAML format, not the platform. There is no scheduler, no Service load-balancing, no PersistentVolume controller, no cross-node self-healing. It is a brilliant way to run a K8s-shaped manifest on a single edge box — but if you rely on Service objects or PVCs, they won’t behave like a cluster.
6. “Quadlet and podman_systemd_generate are two names for the same thing.”
podman_systemd_generate snapshots a currently existing container into a static .service file — change the container and the unit is stale. Quadlet is the reverse: a declarative .container file is the source of truth, and systemd regenerates the unit from it on every daemon-reload. New work should use Quadlet; generate is the legacy path.
7. “restart_policy: always is the safe default.”
always also restarts a container you deliberately stopped (and on daemon restart), which fights an operator doing maintenance. unless-stopped restarts on crash and boot but respects a manual stop — the safer default for most services.
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.
Practice challenges
Work these in order — they escalate from a first container to a runtime-detecting, Vault-authenticated build. Each has a worked solution; try it before you peek.
Challenge 1 (Beginner) — Run and verify a single container.
Write a play that runs redis:7.2-alpine as a container named cache, mapped to host port 6379, with restart_policy: unless-stopped. Run it twice and confirm the second run reports ok, not changed.
<details> <summary>Solution</summary>
- name: Run Redis
hosts: docker_hosts
become: true
tasks:
- name: Ensure redis cache is running
community.docker.docker_container:
name: cache
image: redis:7.2-alpine
state: started
restart_policy: unless-stopped
ports:
- "6379:6379"
Why: the second run matches the running spec, so the comparisons engine returns ok — proof the task is idempotent.
</details>
Challenge 2 (Beginner → Intermediate) — Preview with check mode.
Take the play from Challenge 1, change the image tag to redis:7.4-alpine, and preview what would happen without doing it.
<details> <summary>Solution</summary>
ansible-playbook redis.yml --check --diff
Expected: the task reports changed with a diff showing the image field moving from 7.2 to 7.4, while the running container stays untouched.
Why: --check runs the comparisons engine without side effects; --diff shows the exact field that would trigger the recreate.
</details>
Challenge 3 (Intermediate) — Template a Compose stack.
Render a compose.yaml from a Jinja template that takes api_tag and api_host_port as variables, deploy it with docker_compose_v2, then prove that changing only api_tag restarts only the api service.
<details> <summary>Solution</summary>
templates/compose.yaml.j2:
services:
api:
image: hashicorp/http-echo:{{ api_tag }}
command: ["-text=hello {{ api_tag }}"]
ports: ["{{ api_host_port }}:5678"]
Play:
- hosts: docker_hosts
become: true
vars:
api_tag: "1.0"
api_host_port: 8081
tasks:
- name: Ensure project dir
ansible.builtin.file:
path: /opt/echo
state: directory
mode: '0755'
- name: Render compose.yaml
ansible.builtin.template:
src: compose.yaml.j2
dest: /opt/echo/compose.yaml
mode: '0644'
- name: Deploy
community.docker.docker_compose_v2:
project_src: /opt/echo
state: present
Why: template rewrites the file only when api_tag changes, and docker_compose_v2 restarts only services whose definition moved — so a tag bump touches just api.
</details>
Challenge 4 (Intermediate) — Rootless container that survives reboot.
Deploy an nginx container as a rootless Podman Quadlet unit for user deploy, and make sure it comes back after a reboot.
<details> <summary>Solution</summary>
- name: Rootless nginx via Quadlet
hosts: podman_hosts
become: true
tasks:
- name: Enable lingering so deploy's user manager starts at boot
ansible.builtin.command: loginctl enable-linger deploy
changed_when: false
- name: Drop a Quadlet .container unit owned by deploy
ansible.builtin.copy:
dest: /home/deploy/.config/containers/systemd/web.container
owner: deploy
group: deploy
mode: '0644'
content: |
[Container]
Image=docker.io/library/nginx:1.27-alpine
PublishPort=8080:80
[Install]
WantedBy=default.target
- name: Reload deploy's user systemd and start the unit
become_user: deploy
ansible.builtin.systemd:
daemon_reload: true
name: web.service
state: started
scope: user
Why: enable-linger keeps the rootless user’s systemd alive across logout and reboot, and the Quadlet generator turns web.container into a real web.service on daemon-reload. (Because the start step runs as deploy with scope: user, in production you’d also make sure XDG_RUNTIME_DIR=/run/user/<uid> is set for that connection.)
</details>
Challenge 5 (Advanced) — Registry login with a Vaulted secret, then push.
Log in to registry.example.com using a password stored in Ansible Vault (never plaintext, never printed), build an image from a local Containerfile, and push it.
<details> <summary>Solution</summary>
group_vars/all/vault.yml (encrypted with ansible-vault):
vault_registry_password: "redacted-in-real-life"
Play:
- hosts: build_hosts
become: true
vars:
registry_user: robot$ci
tasks:
- name: Log in (secret from Vault, hidden from logs)
containers.podman.podman_login:
registry: registry.example.com
username: "{{ registry_user }}"
password: "{{ vault_registry_password }}"
no_log: true
- name: Build and push
containers.podman.podman_image:
name: registry.example.com/myapp:{{ git_sha | default('dev') }}
path: ./containers/myapp
build:
file: Containerfile
push: true
validate_certs: true
Run with ansible-playbook push.yml --ask-vault-pass.
Why: Vault encrypts the token at rest, no_log: true keeps it out of stdout, and the two together are the only leak-free way to authenticate from a playbook.
</details>
Challenge 6 (Advanced) — Detect the runtime and branch. Write a play that detects whether the target host has Docker or Podman installed and deploys the same nginx container with the correct collection, without failing on the host that lacks the other.
<details> <summary>Solution</summary>
- hosts: container_hosts
become: true
tasks:
- name: Gather package facts
ansible.builtin.package_facts:
- name: Deploy with Docker
community.docker.docker_container:
name: web
image: nginx:1.27-alpine
state: started
ports: ["8080:80"]
when: "'docker-ce' in ansible_facts.packages or 'docker' in ansible_facts.packages"
- name: Deploy with Podman
containers.podman.podman_container:
name: web
image: docker.io/library/nginx:1.27-alpine
state: started
ports: ["8080:80"]
when: "'podman' in ansible_facts.packages"
Why: package_facts populates ansible_facts.packages, and the when guards run exactly one branch per host — the same desired state expressed in whichever collection the host supports.
</details>
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).
- Idempotency — Running the same play twice leaves the same result; the second run reports no change if nothing drifted.
- Comparisons engine —
docker_container’s field-by-field inspect-and-diff that decides whether to recreate a container. - Check mode —
--check, a dry run that reports what would change without changing anything; pair with--diff. - User namespace — The kernel feature that lets rootless Podman map container-root to an unprivileged host UID (via
/etc/subuid//etc/subgid). - Lingering —
loginctl enable-linger, which keeps a user’s systemd instance running after logout so rootless containers survive reboot. - slirp4netns / pasta — Userspace networking backends for rootless Podman;
pastais the modern default (Podman 4.4+/5.0). - Quadlet generator — The systemd generator that turns
.container/.pod/.kubefiles into real.serviceunits ondaemon-reload. - BuildKit / buildx — Docker’s modern build engine (multi-arch, build secrets);
docker_image_builduses it. - Digest pinning — Referencing an image by its immutable
@sha256:…digest instead of a mutable tag. - Ansible Vault — Ansible’s built-in encryption for secrets at rest (
ansible-vault encrypt_string/create). no_log— Task directive that suppresses a task’s data from output and logs; mandatory on secret-handling tasks.- Ephemeral registry token — A short-lived credential (e.g.
aws ecr get-login-password) fetched at run time instead of stored.
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.