Deploy Container #
After the Docker host is successfully prepared with the optimal runtime and daemon configuration, the next step we must take is running containers on that host. Managing container lifecycles manually using CLIs like docker run or docker stop is very risky in production environments. We have no change records, no certainty of consistent container status, and the process is hard to repeat identically across different servers.
Ansible offers a different approach through the community.docker module ecosystem. Using these modules, we can declare the desired state of our containers — from the image used, exposed ports, mounted volumes, environment variables, restart policies, to health check mechanisms. Ansible automatically detects the running container’s status, compares it with our definition, and takes corrective action if there are differences. This declarative approach guarantees an idempotent, consistent, and fully auditable deployment process.
flowchart TD
Start["Playbook Runs"] --> CheckContainer{"Does the container exist?"}
CheckContainer -. "No" .-> CreateNew["Create New Container (State: started)"]
CheckContainer -. "Yes" .-> CheckConfig{"Are there image/ports/env changes?"}
CheckConfig -- "Yes" --> Recreate["Recreate Container (Remove & Create New)"]
CheckConfig -- "No" --> CheckState{"Is the container running?"}
CheckState -- "No" --> StartExisting["Start the Container (State: started)"]
CheckState -- "Yes" --> Skip["Skip Task (Idempotent - Status OK)"]
CreateNew --> Verify["Run Health Check"]
Recreate --> Verify
StartExisting --> Verify
Verify --> End["Deployment Successful"]Imperative vs Declarative Paradigms in Deployment #
To understand Ansible’s power in managing containers, we must understand the fundamental difference between the imperative model (step-by-step commands) and the declarative model (desired end-state statements).
When using the imperative model with bash commands, we tell the server how to run the container. If the container already exists, the command fails. If we want to change ports, we must stop the old container first then manually create a new one. Conversely, Ansible’s declarative model focuses us on writing what the container’s desired end state is, while Ansible handles the execution step details.
Here’s a comparative table showing the differences between the two approaches:
| Characteristic | Imperative Approach (docker run CLI) | Declarative Approach (Ansible docker_container) |
|---|---|---|
| Status Definition | Expressed as instant action commands. | Expressed as a desired end-state declaration. |
| Idempotency | Not idempotent. Running the same command twice produces port conflict errors or duplicate container names. | Absolutely idempotent. If the server container status already matches, Ansible takes no action. |
| Change Handling | Must manually remove the old container before replacing configuration/ports. | Automatically detects configuration differences and recreates the container with new configuration. |
| Secret Integration | Secrets are often written directly as plaintext in terminal history. | Closely integrated with Ansible Vault for secure sensitive variable encryption. |
| Rollback Ease | Requires rewriting manual commands with the old version tag. | Just change the image tag variable value in the playbook then run it again. |
Installing Ansible Docker Dependencies #
Ansible’s Docker modules aren’t bundled in the Ansible Core package. We must install the community.docker collection first via Ansible Galaxy. Additionally, these modules depend on Docker’s official Python library called docker-py or the docker SDK on the target host to communicate with the Docker daemon socket /var/run/docker.sock.
1. Centralized Collection Installation (Ansible Control Node) #
The community.docker collection is installed on our control machine by running the following command:
ansible-galaxy collection install community.docker
2. Python SDK Setup on the Target Host #
For our playbook to execute Docker modules on the destination server, we must make sure the docker Python library is installed there. We can automate it with the following Ansible task:
# playbooks/tasks/prepare_docker_sdk.yml
---
- name: Ensure pip3 is installed on the system
apt:
name: python3-pip
state: present
when: ansible_os_family == "Debian"
- name: Ensure pip3 is installed on CentOS/RHEL
dnf:
name: python3-pip
state: present
when: ansible_os_family == "RedHat"
- name: Install the Docker SDK for Python using pip
pip:
name: docker
state: present
Using the community.docker.docker_container Module #
The community.docker.docker_container module is the main heart of container lifecycle management. This module supports various target states through the state parameter.
Here are the most commonly used state options:
started: (Default) Guarantees the container is created and running. If the container is already running but its configuration changed, Ansible automatically recreates it.stopped: Guarantees the container is temporarily stopped, but doesn’t remove its definition from the host.present: Creates the container on the host but doesn’t immediately run it.absent: Guarantees the container is stopped and completely removed from the host, including cleaning up related anonymous volumes if desired.
Container Runtime Configuration #
In designing production-grade containers, there are many runtime aspects that must be carefully configured, especially port handling, volume mounting for persistent data, environment variable setup, and determining the restart policy.
1. Port Mapping and Volume Mounting #
When deploying web applications or databases, we must map container internal ports to host ports, and map host directories into the container for data storage that survives container removal.
# ANTI-PATTERN: Writing ports and volumes without considering security or directory structure
- name: Run the Postgresql DB the wrong way
community.docker.docker_container:
name: wrong_database
image: postgres:15
ports:
- "5432:5432" # Exposes the database directly to the public internet
volumes:
- "/tmp/db-data:/var/lib/postgresql/data" # Stores data in /tmp which can be auto-deleted by the system
# CORRECT: Restricting port exposure and using a safe persistent directory path
- name: Run the Postgresql DB the right way
community.docker.docker_container:
name: right_database
image: postgres:15-alpine
state: started
ports:
- "127.0.0.1:5432:5432" # Database only accessible locally (localhost) or via VPN
volumes:
- "/var/lib/postgresql/prod_data:/var/lib/postgresql/data:rw" # Persistent path with active write permission
restart_policy: unless-stopped
2. Additional Runtime Security #
To increase our container security, it’s recommended to restrict root access rights inside the container. We can set the container filesystem to read-only using the read_only option, and only allow data writes to specific folders via tmpfs or defined volumes.
- name: Deploy a hardened static web container
community.docker.docker_container:
name: static-web
image: nginx:alpine
state: started
read_only: true # Makes the entire container root filesystem read-only
tmpfs:
- /var/cache/nginx:uid=101,gid=101,mode=0755
- /var/run:uid=101,gid=101,mode=0755
ports:
- "8080:80"
Sensitive Environment Variable Management #
Production applications always need environment variables like database passwords, API keys, or encryption tokens. Writing these secrets in plaintext in regular playbook files is a fatal security violation that’s very dangerous.
Security Step: Encryption with Ansible Vault #
We must encrypt all secret data using Ansible Vault, then call those encrypted variables inside the task with the additional no_log: true option. The no_log: true option is very important so Ansible doesn’t print those sensitive variable values into our terminal execution log files.
1. Encrypted Secrets File (vars/secrets.yml) #
We lock our sensitive variables using the ansible-vault encrypt vars/secrets.yml command. The encrypted file’s contents will look like this:
# vars/secrets.yml (After being decrypted with the vault password)
---
db_prod_password: "SuperSecretSecureDatabasePassword2026!"
api_key_third_party: "live_abcd1234efgh5678"
2. Playbook Task Using Vault (tasks/deploy_app.yml) #
When running the container deployment task, we call those variables and make sure the system records status without displaying the secrets:
# playbooks/tasks/deploy_app.yml
---
- name: Load secret variables from the vault
include_vars:
file: vars/secrets.yml
- name: Deploy the web application with sensitive credentials
community.docker.docker_container:
name: web-app
image: registry.company.com/webapp:v1.2.0
state: started
env:
DATABASE_URL: "postgresql://postgres:{{ db_prod_password }}@db.local:5432/webapp"
API_TOKEN: "{{ api_key_third_party }}"
APP_ENV: "production"
# no_log hides task parameters from stdout output and Ansible server logs
no_log: true
Idempotent and Zero-Downtime Deployment Patterns #
When we do application code updates, we usually change the image tag version in the playbook (for example from v1.1.0 to v1.2.0). Ansible intelligently detects this image difference, stops the old container, removes it, and runs the new container.
However, this replacement process causes our application to experience downtime for a few seconds. To prevent this in production environments, we can apply a simple rolling update pattern with these steps: pull the new image first, then verify the port before switching network traffic.
Here’s a complete playbook example for idempotent deployment that minimizes downtime:
# playbooks/deploy_webapp_rolling.yml
---
- name: Web Application Rolling Update Deployment
hosts: webservers
vars:
app_image: "nginx"
app_version: "1.25.3-alpine"
app_port: 8080
container_name: "prod-web-app"
tasks:
- name: 1. Pull the new image first before shutting down the old container
community.docker.docker_image:
name: "{{ app_image }}"
tag: "{{ app_version }}"
source: pull
register: pull_result
- name: 2. Collect the running container status
community.docker.docker_container_info:
name: "{{ container_name }}"
register: current_container
- name: 3. Display a message if the container needs updating
debug:
msg: "The container will be updated from the old version to {{ app_version }}"
when:
- current_container.exists
- current_container.container.Config.Image != (app_image + ":" + app_version)
- name: 4. Deploy the new container (replacing the old one if different)
community.docker.docker_container:
name: "{{ container_name }}"
image: "{{ app_image }}:{{ app_version }}"
state: started
restart_policy: unless-stopped
ports:
- "{{ app_port }}:80"
env:
TZ: "Asia/Jakarta"
APP_STATUS: "active"
- name: 5. Wait for the new container to be ready to accept requests (Health Check Verification)
uri:
url: "http://localhost:{{ app_port }}/"
status_code: 200
register: health_check
# Try contacting the endpoint up to 10 times with a 3-second pause per attempt
until: health_check.status == 200
retries: 10
delay: 3
Using Docker’s Native Health Check #
Docker has a built-in health check feature (native health check) that lets the daemon periodically check the application’s condition inside the container. If the application hangs or experiences internal failure, the container status changes to unhealthy. This health status information is very useful so orchestrators or network proxies know when to stop sending traffic to that container.
We can configure these health check parameters directly from the Ansible docker_container task:
- name: Deploy a Node.js container with native Docker healthcheck
community.docker.docker_container:
name: api-service
image: node:18-alpine
state: started
ports:
- "3000:3000"
healthcheck:
# CLI command run inside the container to check health
test: ["CMD", "curl", "-f", "http://localhost:3000/healthz"]
# Run the check every 30 seconds
interval: 30s
# If the command takes longer than 5 seconds, consider it failed
timeout: 5s
# Consecutive failure tolerance before the container is marked unhealthy
retries: 3
# Initial grace period when a container just starts before checks begin
start_period: 10s
By enabling this built-in health check configuration, we move monitoring responsibility to the Docker daemon level. We can also combine it with the restart_policy parameter so the daemon automatically restarts the container if it detects an unhealthy status within a certain period.
Managing Isolated Networks and Volumes #
Besides running the containers themselves, advanced microservices architectures require us to arrange network isolation and persistent storage management separately. Relying on Docker’s automatic default bridge network creation often puts our containers in the same network segment, violating the principle of least privilege.
Ansible provides the community.docker.docker_network and community.docker.docker_volume modules that let us create isolated, secure container support infrastructure before the containers themselves run.
# playbooks/tasks/setup_networks_volumes.yml
---
- name: Create an isolated bridge network for the application backend
community.docker.docker_network:
name: app-backend-net
driver: bridge
internal: true # Containers in this network can't directly access the outside internet
ipam_config:
- subnet: "172.22.0.0/16"
gateway: "172.22.0.1"
- name: Create a bridge network for the public-facing web
community.docker.docker_network:
name: app-public-net
driver: bridge
internal: false # Outside internet access allowed
- name: Create a persistent volume with a custom driver
community.docker.docker_volume:
name: app-db-volume
state: present
driver: local
driver_options:
type: none
device: /var/lib/postgresql/data
o: bind
By attaching containers to the right networks, for example connecting the database only to app-backend-net and the nginx web proxy to both networks (app-public-net and app-backend-net), we create a very secure multi-tier architecture, similar to the DMZ (Demilitarized Zone) concept in traditional networks.
Container Monitoring and Inspection via Ansible #
After containers run, sometimes our playbook needs to fetch dynamic data from them — like internal IP addresses, current health status, or active mount directories — for use by subsequent tasks (for example configuring an nginx load balancer on another host).
We can use the community.docker.docker_container_info module to query detailed container runtime information in real-time:
# playbooks/tasks/inspect_container.yml
---
- name: Collect detailed information about the database container
community.docker.docker_container_info:
name: prod-database
register: db_info
- name: Display the database health status
debug:
msg: "The database is currently in status: {{ db_info.container.State.Health.Status }}"
when: db_info.exists
- name: Fetch the database's internal IP address
set_fact:
db_internal_ip: "{{ db_info.container.NetworkSettings.Networks['app-backend-net'].IPAddress }}"
when:
- db_info.exists
- "'app-backend-net' in db_info.container.NetworkSettings.Networks"
- name: Use the database IP for the web application configuration
debug:
msg: "The application will be connected to the database IP: {{ db_internal_ip }}"
when: db_internal_ip is defined
This inspection automation frees us from having to write string-parsing scripts for docker inspect command output manually, which are often fragile and inconsistent.
Summary #
- Use the Declarative Pattern — Avoid running imperative docker run commands manually in the CLI. Always use the
docker_containermodule to guarantee an idempotent deployment process.- Install the Python SDK on the Target Host — Make sure the
dockerPython library is installed on the destination server so Ansible Docker modules can interact with the Docker daemon socket API.- Tighten Runtime Security — Apply the
read_only: trueoption on containers that don’t need write access to the root filesystem, then usetmpfsmounts to safely write temporary data.- Encrypt with Ansible Vault — Protect all sensitive variables (like database credentials or API keys) using Ansible Vault and add the
no_log: trueoption on deployment tasks.- Minimal-Downtime Deployment — Pull the new image first before updating the container, then install an HTTP endpoint verification task (
urimodule) to ensure the application is ready to serve traffic.- Leverage Native Health Checks — Configure Docker’s built-in health check options (
healthcheck) on thedocker_containermodule to automate application stability monitoring directly by the Docker daemon.
← Previous: Provision Host Next: Ansible vs Docker Compose →