If you manage more than one server, you need Ansible. If you manage even a single server and value reproducibility, you still need Ansible. It replaces the SSH-and-pray approach with declarative, version-controlled, idempotent automation that works the same way every time you run it.

Ansible is agentless: it connects to servers over SSH and executes tasks. No daemon to install, no ports to open, no client software to maintain. Write a YAML playbook describing the desired state of your server, run it, and Ansible makes it so.

Installation

# Install Ansible via pip (recommended for latest version)
pip3 install ansible

# Or via apt (stable but potentially older)
sudo apt install -y ansible

# Verify installation
ansible --version

# Install on macOS
brew install ansible

Inventory: Defining Your Servers

The inventory file tells Ansible which servers to manage and how to connect to them:

# inventory.yml - YAML format (recommended)
all:
  children:
    webservers:
      hosts:
        web1:
          ansible_host: 192.168.1.101
        web2:
          ansible_host: 192.168.1.102
      vars:
        http_port: 80

    databases:
      hosts:
        db1:
          ansible_host: 192.168.1.110
          ansible_user: dbadmin
        db2:
          ansible_host: 192.168.1.111
          ansible_user: dbadmin

    docker_hosts:
      hosts:
        docker1:
          ansible_host: 192.168.1.120
        docker2:
          ansible_host: 192.168.1.121

  vars:
    ansible_user: admin
    ansible_python_interpreter: /usr/bin/python3
    ansible_ssh_private_key_file: ~/.ssh/id_ed25519

Ad-Hoc Commands

Before writing playbooks, ad-hoc commands let you run one-off tasks across your infrastructure:

# Ping all servers (test connectivity)
ansible all -i inventory.yml -m ping

# Check uptime on all servers
ansible all -i inventory.yml -m command -a "uptime"

# Install a package on webservers
ansible webservers -i inventory.yml -m apt -a "name=htop state=present" --become

# Restart a service on docker hosts
ansible docker_hosts -i inventory.yml -m systemd -a "name=docker state=restarted" --become

# Copy a file to all servers
ansible all -i inventory.yml -m copy -a "src=./motd dest=/etc/motd" --become

# Gather facts about a specific host
ansible docker1 -i inventory.yml -m setup

Playbooks

Playbooks are the core of Ansible automation. They are YAML files that define a set of tasks to execute in order:

# deploy-stack.yml
---
- name: Deploy monitoring stack
  hosts: docker_hosts
  become: true
  vars:
    monitoring_dir: /opt/docker/monitoring
    grafana_password: "{{ vault_grafana_password }}"

  tasks:
    - name: Create monitoring directory
      file:
        path: "{{ monitoring_dir }}"
        state: directory
        owner: "1000"
        group: "1000"
        mode: '0755'

    - name: Copy Docker Compose file
      template:
        src: templates/monitoring-compose.yml.j2
        dest: "{{ monitoring_dir }}/docker-compose.yml"
        owner: "1000"
        group: "1000"
        mode: '0644'
      notify: Restart monitoring stack

    - name: Copy Prometheus configuration
      template:
        src: templates/prometheus.yml.j2
        dest: "{{ monitoring_dir }}/prometheus.yml"
        owner: "1000"
        group: "1000"
        mode: '0644'
      notify: Reload Prometheus

    - name: Copy alert rules
      copy:
        src: files/alert-rules.yml
        dest: "{{ monitoring_dir }}/alert-rules.yml"
      notify: Reload Prometheus

    - name: Ensure monitoring stack is running
      community.docker.docker_compose_v2:
        project_src: "{{ monitoring_dir }}"
        state: present

  handlers:
    - name: Restart monitoring stack
      community.docker.docker_compose_v2:
        project_src: "{{ monitoring_dir }}"
        state: restarted

    - name: Reload Prometheus
      uri:
        url: http://localhost:9090/-/reload
        method: POST
        status_code: 200
# Run the playbook
ansible-playbook -i inventory.yml deploy-stack.yml

# Run in check mode (dry run, no changes made)
ansible-playbook -i inventory.yml deploy-stack.yml --check

# Run with verbose output
ansible-playbook -i inventory.yml deploy-stack.yml -v

# Run against specific hosts
ansible-playbook -i inventory.yml deploy-stack.yml --limit docker1

# Show what would change without connecting
ansible-playbook -i inventory.yml deploy-stack.yml --check --diff

Roles: Reusable Automation

Roles are Ansible's way of packaging related tasks, variables, templates, and files into a reusable unit:

# Create a role with the standard structure
ansible-galaxy init roles/docker

# roles/docker/
# |- defaults/main.yml      # Default variables (lowest priority)
# |- files/                  # Static files to copy
# |- handlers/main.yml       # Handler definitions
# |- meta/main.yml           # Role metadata and dependencies
# |- tasks/main.yml          # Main task list
# |- templates/              # Jinja2 templates
# |- vars/main.yml           # Variables (higher priority than defaults)
# roles/docker/defaults/main.yml
---
docker_edition: "ce"
docker_version: ""
docker_users: []
docker_log_driver: "json-file"
docker_log_max_size: "10m"
docker_log_max_file: "3"
docker_live_restore: true
docker_default_address_pools:
  - base: "172.17.0.0/12"
    size: 24
# roles/docker/tasks/main.yml
---
- name: Install prerequisites
  apt:
    name:
      - apt-transport-https
      - ca-certificates
      - curl
      - gnupg
    state: present
    update_cache: true

- name: Add Docker GPG key
  apt_key:
    url: "https://download.docker.com/linux/{{ ansible_distribution | lower }}/gpg"
    state: present

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

- name: Install Docker
  apt:
    name:
      - "docker-{{ docker_edition }}"
      - "docker-{{ docker_edition }}-cli"
      - containerd.io
      - docker-compose-plugin
    state: present

- name: Add users to docker group
  user:
    name: "{{ item }}"
    groups: docker
    append: true
  loop: "{{ docker_users }}"

- name: Configure Docker daemon
  template:
    src: daemon.json.j2
    dest: /etc/docker/daemon.json
    mode: '0644'
  notify: Restart Docker

- name: Ensure Docker is running
  systemd:
    name: docker
    state: started
    enabled: true
# roles/docker/templates/daemon.json.j2
{
  "log-driver": "{{ docker_log_driver }}",
  "log-opts": {
    "max-size": "{{ docker_log_max_size }}",
    "max-file": "{{ docker_log_max_file }}"
  },
  "live-restore": {{ docker_live_restore | to_json }},
  "storage-driver": "overlay2",
  "default-address-pools": {{ docker_default_address_pools | to_json }}
}
# roles/docker/handlers/main.yml
---
- name: Restart Docker
  systemd:
    name: docker
    state: restarted

Variables and Jinja2 Templates

Ansible uses Jinja2 for templating, allowing you to generate configuration files dynamically:

# templates/nginx-vhost.conf.j2
{% for site in nginx_sites %}
server {
    listen 80;
    server_name {{ site.domain }};
    return 301 https://$server_name$request_uri;
}

server {
    listen 443 ssl http2;
    server_name {{ site.domain }};

    ssl_certificate /etc/letsencrypt/live/{{ site.domain }}/fullchain.pem;
    ssl_certificate_key /etc/letsencrypt/live/{{ site.domain }}/privkey.pem;

    location / {
        proxy_pass http://{{ site.backend }};
        proxy_set_header Host $host;
        proxy_set_header X-Real-IP $remote_addr;
        proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
        proxy_set_header X-Forwarded-Proto $scheme;
{% if site.websocket | default(false) %}
        proxy_http_version 1.1;
        proxy_set_header Upgrade $http_upgrade;
        proxy_set_header Connection "upgrade";
{% endif %}
    }
}
{% endfor %}
# Variables to feed the template
# group_vars/webservers.yml
nginx_sites:
  - domain: grafana.example.com
    backend: localhost:3000
    websocket: true
  - domain: nextcloud.example.com
    backend: localhost:8080
    websocket: false
  - domain: usulnet.example.com
    backend: localhost:7443
    websocket: true

Conditionals and Loops

- name: Install packages based on OS family
  apt:
    name: "{{ item }}"
    state: present
  loop:
    - curl
    - wget
    - htop
  when: ansible_os_family == "Debian"

- name: Install packages on RHEL
  dnf:
    name: "{{ item }}"
    state: present
  loop:
    - curl
    - wget
    - htop
  when: ansible_os_family == "RedHat"

- name: Create service directories
  file:
    path: "/opt/docker/{{ item.name }}"
    state: directory
    owner: "{{ item.owner | default('root') }}"
  loop:
    - { name: traefik, owner: "1000" }
    - { name: monitoring, owner: "1000" }
    - { name: backups }
  loop_control:
    label: "{{ item.name }}"

- name: Deploy only if Docker is installed
  community.docker.docker_compose_v2:
    project_src: /opt/docker/monitoring
    state: present
  when: "'docker' in ansible_facts.packages"

Ansible Galaxy

Galaxy is Ansible's repository of community-contributed roles. Use it to avoid reinventing common configurations:

# Install a role from Galaxy
ansible-galaxy install geerlingguy.docker
ansible-galaxy install geerlingguy.postgresql
ansible-galaxy install geerlingguy.certbot

# Install roles from a requirements file
# requirements.yml
---
roles:
  - name: geerlingguy.docker
    version: "7.1.0"
  - name: geerlingguy.certbot
    version: "5.1.0"

collections:
  - name: community.docker
    version: ">=3.0.0"

# Install all requirements
ansible-galaxy install -r requirements.yml
ansible-galaxy collection install -r requirements.yml

Ansible Vault: Secrets Management

# Create an encrypted variables file
ansible-vault create group_vars/all/vault.yml

# Edit an encrypted file
ansible-vault edit group_vars/all/vault.yml

# Encrypt an existing file
ansible-vault encrypt secrets.yml

# Decrypt a file
ansible-vault decrypt secrets.yml

# View encrypted file contents
ansible-vault view group_vars/all/vault.yml

# Encrypt a single string
ansible-vault encrypt_string 'my_secret_password' --name 'db_password'

# Contents of vault.yml:
---
vault_db_password: "supersecret_password"
vault_grafana_password: "grafana_admin_pass"
vault_api_key: "sk-1234567890abcdef"

# Reference vault variables in playbooks:
vars:
  db_password: "{{ vault_db_password }}"

# Run playbook with vault password
ansible-playbook deploy.yml --ask-vault-pass

# Or use a password file
ansible-playbook deploy.yml --vault-password-file ~/.vault_pass
Warning: Never commit unencrypted secrets to version control. Use .gitignore to exclude vault password files. Ansible Vault encrypts files with AES-256, which is strong, but only if the vault password itself is strong. Use a randomly generated password of at least 32 characters.

Docker Module: Managing Containers with Ansible

# Deploy a Docker container with Ansible
- name: Deploy applications with Docker
  hosts: docker_hosts
  become: true
  collections:
    - community.docker

  tasks:
    - name: Create Docker network
      docker_network:
        name: proxy
        state: present

    - name: Pull latest images
      docker_image:
        name: "{{ item }}"
        source: pull
        force_source: true
      loop:
        - traefik:v3.0
        - grafana/grafana:latest
        - prom/prometheus:latest

    - name: Deploy with Docker Compose
      docker_compose_v2:
        project_src: /opt/docker/{{ item }}
        state: present
        pull: policy
      loop:
        - traefik
        - monitoring
        - backups

    - name: Remove unused images
      docker_prune:
        images: true
        images_filters:
          dangling: true

    - name: Check container health
      docker_container_info:
        name: "{{ item }}"
      loop:
        - traefik
        - prometheus
        - grafana
      register: container_info

    - name: Report unhealthy containers
      debug:
        msg: "Container {{ item.item }} is {{ item.container.State.Status }}"
      loop: "{{ container_info.results }}"
      when: item.container.State.Status != "running"

CI/CD Integration

# .github/workflows/ansible-deploy.yml
name: Deploy Infrastructure
on:
  push:
    branches: [main]
    paths:
      - 'ansible/**'

jobs:
  lint:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - name: Run ansible-lint
        uses: ansible/ansible-lint@main

  deploy:
    needs: lint
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4

      - name: Set up Python
        uses: actions/setup-python@v5
        with:
          python-version: '3.12'

      - name: Install Ansible
        run: pip install ansible community.docker

      - name: Configure SSH
        run: |
          mkdir -p ~/.ssh
          echo "${{ secrets.SSH_PRIVATE_KEY }}" > ~/.ssh/id_ed25519
          chmod 600 ~/.ssh/id_ed25519
          ssh-keyscan -H ${{ secrets.SERVER_IP }} >> ~/.ssh/known_hosts

      - name: Run playbook
        env:
          ANSIBLE_VAULT_PASSWORD: ${{ secrets.VAULT_PASSWORD }}
        run: |
          echo "$ANSIBLE_VAULT_PASSWORD" > /tmp/vault_pass
          ansible-playbook \
            -i ansible/inventory/production.yml \
            ansible/playbooks/deploy.yml \
            --vault-password-file /tmp/vault_pass
          rm /tmp/vault_pass
Tip: Ansible excels at the initial setup and ongoing configuration of servers. For real-time container management and monitoring, combine Ansible with a tool like usulnet: use Ansible to deploy and configure usulnet across your infrastructure, then use usulnet's web interface for day-to-day container operations, monitoring, and troubleshooting.

Best Practices

  • Always use check mode first. Run with --check --diff before making changes to production.
  • Keep playbooks idempotent. Running a playbook twice should produce the same result.
  • Use roles for reusable logic. If you do the same thing in two playbooks, extract it into a role.
  • Version pin Galaxy roles. Do not use latest; specify exact versions in requirements.yml.
  • Test in staging first. Maintain separate inventory files for staging and production.
  • Use tags for selective execution. Tag tasks so you can run subsets of a playbook.
  • Keep secrets in Vault. Never hardcode passwords or keys in playbooks.