Ansible for Server Management: Automating Everything from Deploy to Maintenance
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
.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
Best Practices
- Always use check mode first. Run with
--check --diffbefore 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 inrequirements.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.