Ansible Lesson 29 of 42

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

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.

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.

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

Security Notes

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