Ansible Lesson 29 of 42

Ansible for Containers, In Depth: community.docker, containers.podman, Compose, Image Builds & Registry Lifecycle

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_containerpodman_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:

Prerequisites

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:

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:

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:

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:

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:

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:

See the performance-tuning lesson for the full toolkit.

Security hardening beyond the basics

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

Security Notes

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

  1. What’s the modern Compose module name in community.docker?
  2. What does :Z mean on a Podman volume mount?
  3. What’s a Pod in Podman?
  4. Where do Quadlet files live for a user?
  5. How do you log into ECR with docker_login?
  6. What’s the difference between pull: always and pull: missing?
  7. What does comparisons: allow_more_present do?
  8. Why is no_log: true mandatory on registry login tasks?

Exercise

Build a complete role containerized_web_stack that:

  1. Detects whether the target has Docker or Podman installed (use ansible_facts.packages).
  2. Conditionally branches: if Docker, use community.docker; if Podman, use containers.podman.
  3. Pulls a templated Compose file (Docker) or generates Quadlet units (Podman) from the same Jinja template.
  4. Configures log rotation on the container daemon (/etc/docker/daemon.json for Docker, containers.conf for Podman).
  5. 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).
  6. Includes a validate.yml that confirms the stack responds with HTTP 200 on https://....

Test on a Docker host (Ubuntu) and a Podman host (RHEL) — confirm both produce identical behavior.

Cert Mapping

Glossary

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.

ansiblecontainersdockerpodmancommunity-dockercontainers-podmancomposeimage-buildregistrykloudvin
Need this built for real?

Vinod is a Senior Cloud Architect (22+ yrs) — available for Azure / AWS / GCP architecture, landing zones, and migrations.

Work with me

Comments