How to Orchestrate Containers with Ansible Docker Module: Automation and Management Guide

How to Orchestrating Containers with Ansible Docker Module

Somewhere between “a few docker run commands in a README” and “a full Kubernetes cluster,” there’s a huge amount of real-world container infrastructure that’s best managed with plain configuration management — and Ansible’s Docker modules are, in my experience, the most underrated tool for that middle ground. This guide covers everything from installing Docker itself via Ansible to building multi-container application stacks, all declaratively, all idempotent, all re-runnable without side effects.

Why Ansible for Docker?

Prerequisites

pip install ansible docker

Install the community Docker collection (the modules used to ship in ansible-core, they’ve since moved to the community.docker collection):

ansible-galaxy collection install community.docker

Verify:

ansible-galaxy collection list | grep docker

Expected output:

community.docker    4.1.0

Inventory Setup

# inventory.ini
[docker_hosts]

docker-host-01 ansible_host=203.0.113.10 ansible_user=ubuntu docker-host-02 ansible_host=203.0.113.11 ansible_user=ubuntu

Step 1: Installing Docker Itself with Ansible

Before orchestrating containers, you need Docker installed on the target hosts. Here’s a full playbook for that.

# install-docker.yml
---
- name: Install Docker Engine
  hosts: docker_hosts
  become: true
  tasks:
    - name: Install prerequisite packages
      apt:
        name:
          - ca-certificates
          - curl
          - gnupg
        state: present
        update_cache: true

    - name: Add Docker's official GPG key
      apt_key:
        url: https://download.docker.com/linux/ubuntu/gpg
        state: present

    - name: Add Docker repository
      apt_repository:
        repo: "deb https://download.docker.com/linux/ubuntu {{ ansible_distribution_release }} stable"
        state: present

    - name: Install Docker Engine
      apt:
        name:
          - docker-ce
          - docker-ce-cli
          - containerd.io
          - docker-compose-plugin
        state: present
        update_cache: true

    - name: Ensure Docker service is running
      systemd:
        name: docker
        state: started
        enabled: true

    - name: Add ansible_user to the docker group
      user:
        name: "{{ ansible_user }}"
        groups: docker
        append: true

    - name: Install Python Docker SDK (required by community.docker modules)
      pip:
        name: docker
        state: present

Run it:

ansible-playbook -i inventory.ini install-docker.yml

Expected output (abridged):

PLAY [Install Docker Engine] **************************************

TASK [Ensure Docker service is running] ***************************
changed: [docker-host-01]
changed: [docker-host-02]

PLAY RECAP **********************************************************
docker-host-01 : ok=6 changed=5 unreachable=0 failed=0
docker-host-02 : ok=6 changed=5 unreachable=0 failed=0

Step 2: Running a Single Container with community.docker.docker_container

# run-nginx.yml
---
- name: Run Nginx container
  hosts: docker_hosts
  become: true
  tasks:
    - name: Start Nginx container
      community.docker.docker_container:
        name: web-nginx
        image: nginx:latest
        state: started
        restart_policy: unless-stopped
        ports:
          - "80:80"
        env:
          NGINX_HOST: "{{ ansible_host }}"
ansible-playbook -i inventory.ini run-nginx.yml

Expected output:

TASK [Start Nginx container] ****************************************
changed: [docker-host-01]
changed: [docker-host-02]

Run it again immediately — because Ansible checks current state before acting, nothing changes the second time:

TASK [Start Nginx container] ****************************************
ok: [docker-host-01]
ok: [docker-host-02]

That ok instead of changed on the second run is the idempotency guarantee in action.

Step 3: Pulling and Managing Images with docker_image

- name: Pull a specific image tag
  community.docker.docker_image:
    name: redis
    tag: "7.4-alpine"
    source: pull

Step 4: Managing Networks with docker_network

- name: Create an application network
  community.docker.docker_network:
    name: app-net
    driver: bridge

- name: Attach container to the network
  community.docker.docker_container:
    name: web-nginx
    image: nginx:latest
    networks:
      - name: app-net
    state: started

Step 5: Managing Volumes with docker_volume

- name: Create a named volume for persistent data
  community.docker.docker_volume:
    name: app-data
    state: present

- name: Mount volume in container
  community.docker.docker_container:
    name: app-db
    image: postgres:16
    volumes:
      - app-data:/var/lib/postgresql/data
    env:
      POSTGRES_PASSWORD: "{{ vault_db_password }}"
    networks:
      - name: app-net
    state: started

Note the vault_db_password reference — sensitive values like database passwords should always come from Ansible Vault rather than being hardcoded in the playbook:

ansible-vault create group_vars/docker_hosts/vault.yml
vault_db_password: "SuperSecretPassword123!"

Run the playbook with:

ansible-playbook -i inventory.ini deploy-app.yml --ask-vault-pass

Step 6: A Complete Multi-Container Application Stack

Here’s a realistic example bringing together a web app, a database, and a reverse proxy — the kind of stack you’d otherwise reach for Compose to define, but expressed as an Ansible playbook so it can be rolled out across a fleet of hosts consistently.

# deploy-app.yml
---
- name: Deploy full application stack
  hosts: docker_hosts
  become: true
  vars_files:
    - group_vars/docker_hosts/vault.yml

  tasks:
    - name: Create application network
      community.docker.docker_network:
        name: app-net
        state: present

    - name: Create database volume
      community.docker.docker_volume:
        name: app-db-data
        state: present

    - name: Run PostgreSQL database
      community.docker.docker_container:
        name: app-db
        image: postgres:16
        state: started
        restart_policy: unless-stopped
        networks:
          - name: app-net
        volumes:
          - app-db-data:/var/lib/postgresql/data
        env:
          POSTGRES_DB: appdb
          POSTGRES_USER: appuser
          POSTGRES_PASSWORD: "{{ vault_db_password }}"

    - name: Run application backend
      community.docker.docker_container:
        name: app-backend
        image: myregistry.example.com/app-backend:1.4.2
        state: started
        restart_policy: unless-stopped
        networks:
          - name: app-net
        env:
          DATABASE_URL: "postgresql://appuser:{{ vault_db_password }}@app-db:5432/appdb"

    - name: Run Nginx reverse proxy
      community.docker.docker_container:
        name: app-proxy
        image: nginx:latest
        state: started
        restart_policy: unless-stopped
        ports:
          - "80:80"
        networks:
          - name: app-net
        volumes:
          - /etc/nginx/conf.d/app.conf:/etc/nginx/conf.d/default.conf:ro

Run it:

ansible-playbook -i inventory.ini deploy-app.yml --ask-vault-pass

Rolling Updates and Image Changes

To roll out a new backend image version, just bump the tag and re-run:

    - name: Run application backend
      community.docker.docker_container:
        name: app-backend
        image: myregistry.example.com/app-backend:1.5.0
        state: started
        pull: true

Setting pull: true forces Ansible to check the registry for a newer image before recreating the container, giving you a one-line rolling update across every host in your inventory.

ansible-playbook -i inventory.ini deploy-app.yml --limit docker-host-01
ansible-playbook -i inventory.ini deploy-app.yml --limit docker-host-02

Running with --limit one host at a time gives you a manual rolling deployment — update host 1, verify health, then move to host 2.

Gathering Container Facts

- name: Gather info about a running container
  community.docker.docker_container_info:
    name: app-backend
  register: backend_info

- name: Show container status
  debug:
    msg: "{{ backend_info.container.State.Status }}"

Expected output:

TASK [Show container status] ****************************************
ok: [docker-host-01] => {
    "msg": "running"
}

This mirrors exactly the kind of data you’d get from docker inspect, but consumable directly inside a playbook for conditional logic (e.g., only restart a downstream service if the upstream one is healthy).

Networking and Security Considerations

Best Practices

  1. Use tags in your images, never latest, in any playbook meant for repeatable production deployments — pull: true combined with a floating tag can silently deploy different code to different hosts if pulls happen at different times.
  2. Split playbooks by concern — one for Docker installation, one for networks/volumes, one for application deployment — so you can re-run just the piece that changed.
  3. Use docker_container_info for health-gated rollouts rather than blindly restarting every host at once.
  4. Keep secrets in Ansible Vault, and rotate the vault password periodically.
  5. Test playbooks against a staging inventory group before running against production hosts.

Troubleshooting

Summary

Ansible’s community.docker collection gives you a declarative, idempotent way to manage everything from Docker installation itself to multi-container application stacks with networks, volumes, and rolling image updates — all without needing a full Kubernetes cluster. It’s a genuinely good fit for teams running a modest fleet of Docker hosts who want infrastructure-as-code discipline without taking on Kubernetes’ operational complexity. And because the underlying primitives are the same Docker Engine API concepts covered elsewhere in this series — containers, networks, volumes, images — everything you already know about Docker itself carries over directly into how you write these playbooks.

References

Exit mobile version