Introduction

HashiCorp Consul provides service discovery, health checking, KV store, and service mesh (Consul Connect) for distributed infrastructure. Ansible automates the entire stack: server cluster bootstrap, client agent deployment on all hosts, service registration, DNS configuration, ACLs, and Connect sidecar proxies.

Deploy Consul Server Cluster

---
- name: Deploy Consul servers
  hosts: consul_servers
  become: true
  vars:
    consul_version: "1.19.0"
    consul_datacenter: dc1
    consul_encrypt_key: "{{ vault_consul_encrypt_key }}"
    consul_data_dir: /opt/consul/data
  tasks:
    - name: Download Consul
      ansible.builtin.get_url:
        url: "https://releases.hashicorp.com/consul/{{ consul_version }}/consul_{{ consul_version }}_linux_amd64.zip"
        dest: /tmp/consul.zip

    - name: Install unzip
      ansible.builtin.package:
        name: unzip
        state: present

    - name: Extract Consul
      ansible.builtin.unarchive:
        src: /tmp/consul.zip
        dest: /usr/local/bin/
        remote_src: true
        mode: '0755'

    - name: Create Consul user
      ansible.builtin.user:
        name: consul
        system: true
        shell: /usr/sbin/nologin

    - name: Create directories
      ansible.builtin.file:
        path: "{{ item }}"
        state: directory
        owner: consul
        mode: '0750'
      loop:
        - /etc/consul.d
        - "{{ consul_data_dir }}"

    - name: Deploy server config
      ansible.builtin.template:
        src: consul-server.hcl.j2
        dest: /etc/consul.d/consul.hcl
        owner: consul
        mode: '0640'
      notify: restart consul

    - name: Create systemd service
      ansible.builtin.copy:
        dest: /etc/systemd/system/consul.service
        content: |
          [Unit]
          Description=HashiCorp Consul
          After=network-online.target
          [Service]
          User=consul
          Group=consul
          ExecStart=/usr/local/bin/consul agent -config-dir=/etc/consul.d
          ExecReload=/bin/kill -HUP $MAINPID
          KillMode=process
          Restart=on-failure
          RestartSec=5
          LimitNOFILE=65536
          [Install]
          WantedBy=multi-user.target
        mode: '0644'
      notify:
        - daemon reload
        - restart consul

    - name: Allow Consul through firewall
      ansible.posix.firewalld:
        port: "{{ item }}/tcp"
        permanent: true
        state: enabled
        immediate: true
      loop: ["8300", "8301", "8302", "8500", "8600"]

    - name: Start Consul
      ansible.builtin.service:
        name: consul
        state: started
        enabled: true

  handlers:
    - name: daemon reload
      ansible.builtin.systemd:
        daemon_reload: true
    - name: restart consul
      ansible.builtin.service:
        name: consul
        state: restarted

Server Config Template

# templates/consul-server.hcl.j2
datacenter = "{{ consul_datacenter }}"
data_dir   = "{{ consul_data_dir }}"
node_name  = "{{ inventory_hostname }}"

server           = true
bootstrap_expect = {{ groups['consul_servers'] | length }}

bind_addr   = "{{ ansible_default_ipv4.address }}"
client_addr = "0.0.0.0"

encrypt = "{{ consul_encrypt_key }}"

retry_join = [
{% for host in groups['consul_servers'] %}
{% if host != inventory_hostname %}
  "{{ hostvars[host].ansible_default_ipv4.address }}",
{% endif %}
{% endfor %}
]

ui_config {
  enabled = true
}

connect {
  enabled = true
}

addresses {
  dns  = "0.0.0.0"
  http = "0.0.0.0"
}

ports {
  dns  = 8600
  http = 8500
}

performance {
  raft_multiplier = 1
}

telemetry {
  prometheus_retention_time = "24h"
  disable_hostname = true
}

Deploy Client Agents

---
- name: Deploy Consul clients
  hosts: all:!consul_servers
  become: true
  tasks:
    - name: Install Consul binary
      # ... same download steps ...

    - name: Deploy client config
      ansible.builtin.template:
        src: consul-client.hcl.j2
        dest: /etc/consul.d/consul.hcl
        owner: consul
        mode: '0640'
      notify: restart consul
# templates/consul-client.hcl.j2
datacenter = "{{ consul_datacenter }}"
data_dir   = "{{ consul_data_dir }}"
node_name  = "{{ inventory_hostname }}"

server = false
bind_addr = "{{ ansible_default_ipv4.address }}"
encrypt = "{{ consul_encrypt_key }}"

retry_join = [
{% for host in groups['consul_servers'] %}
  "{{ hostvars[host].ansible_default_ipv4.address }}",
{% endfor %}
]

connect {
  enabled = true
}

Register Services

- name: Register web service
  ansible.builtin.copy:
    dest: /etc/consul.d/web-service.hcl
    content: |
      service {
        name = "web"
        port = 8080
        tags = ["production", "v1"]

        meta {
          version = "1.0"
          environment = "production"
        }

        check {
          http     = "http://localhost:8080/health"
          interval = "10s"
          timeout  = "3s"
        }

        connect {
          sidecar_service {
            proxy {
              upstreams {
                destination_name = "api"
                local_bind_port  = 9091
              }
              upstreams {
                destination_name = "database"
                local_bind_port  = 5432
              }
            }
          }
        }
      }
    owner: consul
    mode: '0644'
  notify: reload consul

KV Store

- name: Write key-value pairs
  ansible.builtin.uri:
    url: "http://localhost:8500/v1/kv/{{ item.key }}"
    method: PUT
    body: "{{ item.value }}"
  loop:
    - { key: "config/app/db_host", value: "db.example.com" }
    - { key: "config/app/cache_ttl", value: "300" }
    - { key: "config/app/log_level", value: "info" }

- name: Read key-value
  ansible.builtin.uri:
    url: "http://localhost:8500/v1/kv/config/app/db_host?raw"
    return_content: true
  register: kv_result

DNS Integration

- name: Configure systemd-resolved to use Consul DNS
  ansible.builtin.copy:
    dest: /etc/systemd/resolved.conf.d/consul.conf
    content: |
      [Resolve]
      DNS=127.0.0.1:8600
      Domains=~consul
    mode: '0644'
  notify: restart resolved

# Query services via DNS
# dig @127.0.0.1 -p 8600 web.service.consul SRV

ACL Bootstrap

- name: Bootstrap ACL system
  ansible.builtin.uri:
    url: "http://localhost:8500/v1/acl/bootstrap"
    method: PUT
    status_code: [200, 403]
  register: acl_bootstrap
  run_once: true

- name: Save bootstrap token
  ansible.builtin.set_fact:
    consul_master_token: "{{ acl_bootstrap.json.SecretID }}"
  when: acl_bootstrap.status == 200
  run_once: true

Health Check

- name: Check Consul cluster members
  ansible.builtin.command: consul members
  register: members
  changed_when: false

- name: Check leader
  ansible.builtin.uri:
    url: "http://localhost:8500/v1/status/leader"
    return_content: true
  register: leader

- name: Check service health
  ansible.builtin.uri:
    url: "http://localhost:8500/v1/health/service/web?passing"
    return_content: true
  register: healthy_services

Troubleshooting

Agent Not Joining

- name: Test connectivity to servers
  ansible.builtin.wait_for:
    host: "{{ hostvars[item].ansible_default_ipv4.address }}"
    port: 8301
    timeout: 5
  loop: "{{ groups['consul_servers'] }}"

Generate Encrypt Key

consul keygen

Conclusion

Consul provides service discovery, health checking, KV configuration, and service mesh from a single tool. Ansible deploys the server cluster and client agents, registers services with health checks, configures DNS integration so applications resolve service.consul names, and bootstraps ACLs. Combined with Nomad and Vault, it forms the complete HashiCorp infrastructure stack — all managed as code.