This skill guides writing Ansible playbooks for server configuration. Use when hardening servers, installing packages, or automating post-provisioning tasks that cloud-init cannot handle.
This skill is limited to using the following tools:
| Use Cloud-Init When | Use Ansible When |
|---|---|
| First boot only | Re-running config on existing servers |
| Simple package install | Complex multi-step configuration |
| Basic user creation | Role-based configuration |
| Immutable infrastructure | Mutable servers needing updates |
Rule of thumb: Cloud-init for initial provisioning, Ansible for ongoing management.
infra/ansible/
├── playbook.yml # Main playbook
├── requirements.yml # Galaxy dependencies
├── hosts.ini # Inventory (git-ignored)
├── hosts.ini.example # Inventory template
├── group_vars/
│ └── all.yml # Shared variables
└── roles/
└── custom_role/
├── tasks/main.yml
├── handlers/main.yml
└── templates/
# hosts.ini
[web]
192.168.1.1 ansible_user=root
[db]
192.168.1.2 ansible_user=root
[all:vars]
ansible_python_interpreter=/usr/bin/python3
# Generate inventory from Terraform output
SERVER_IP=$(cd infra && tofu output -raw server_ip)
cat > infra/ansible/hosts.ini << EOF
[web]
$SERVER_IP ansible_user=root
EOF
---
- name: Configure web servers
hosts: web
become: true
vars:
timezone: "UTC"
swap_size_mb: "2048"
tasks:
- name: Update apt cache
ansible.builtin.apt:
update_cache: true
cache_valid_time: 3600
- name: Install packages
ansible.builtin.apt:
name:
- docker.io
- fail2ban
- ufw
state: present
---
- name: Configure web servers
hosts: web
become: true
vars:
security_autoupdate_reboot: true
security_autoupdate_reboot_time: "03:00"
roles:
- role: geerlingguy.swap
when: ansible_swaptotal_mb < 1
- role: geerlingguy.docker
- role: security
- name: Install required packages
ansible.builtin.apt:
name:
- curl
- ca-certificates
- gnupg
- fail2ban
- ufw
- ntp
state: present
update_cache: true
- name: Check if Docker is installed
ansible.builtin.command: docker --version
register: docker_installed
ignore_errors: true
changed_when: false
- name: Install Docker via convenience script
ansible.builtin.shell: curl -fsSL https://get.docker.com | sh
when: docker_installed.rc != 0
args:
creates: /usr/bin/docker
- name: Ensure Docker is running
ansible.builtin.systemd:
name: docker
state: started
enabled: true
- name: Disable SSH password authentication
ansible.builtin.lineinfile:
path: /etc/ssh/sshd_config
regexp: "^#?PasswordAuthentication"
line: "PasswordAuthentication no"
notify: Restart ssh
- name: Disable SSH root login with password
ansible.builtin.lineinfile:
path: /etc/ssh/sshd_config
regexp: "^#?PermitRootLogin"
line: "PermitRootLogin prohibit-password"
notify: Restart ssh
handlers:
- name: Restart ssh
ansible.builtin.systemd:
name: ssh # Ubuntu uses 'ssh', not 'sshd'
state: restarted
- name: Configure fail2ban for SSH
ansible.builtin.copy:
dest: /etc/fail2ban/jail.local
content: |
[sshd]
enabled = true
port = ssh
filter = sshd
logpath = /var/log/auth.log
maxretry = 5
bantime = 3600
findtime = 600
mode: "0644"
notify: Restart fail2ban
- name: Ensure fail2ban is running
ansible.builtin.systemd:
name: fail2ban
state: started
enabled: true
handlers:
- name: Restart fail2ban
ansible.builtin.systemd:
name: fail2ban
state: restarted
- name: Set UFW default policies
community.general.ufw:
direction: "{{ item.direction }}"
policy: "{{ item.policy }}"
loop:
- { direction: incoming, policy: deny }
- { direction: outgoing, policy: allow }
- name: Allow specified ports through UFW
community.general.ufw:
rule: allow
port: "{{ item }}"
proto: tcp
loop:
- 22 # SSH
- 80 # HTTP
- 443 # HTTPS
- name: Enable UFW
community.general.ufw:
state: enabled
- name: Configure sysctl for performance
ansible.posix.sysctl:
name: "{{ item.name }}"
value: "{{ item.value }}"
state: present
reload: true
loop:
- { name: vm.swappiness, value: "10" }
- { name: net.core.somaxconn, value: "65535" }
- name: Set timezone
community.general.timezone:
name: "{{ timezone }}"
- name: Remove snapd
ansible.builtin.apt:
name: snapd
state: absent
purge: true
ignore_errors: true
- name: Remove snap directories
ansible.builtin.file:
path: "{{ item }}"
state: absent
loop:
- /snap
- /var/snap
- /var/lib/snapd
---
roles:
- name: geerlingguy.swap
version: 2.0.0
- name: geerlingguy.docker
version: 7.4.1
collections:
- name: community.general
- name: ansible.posix
ansible-galaxy install -r requirements.yml --force
ANSIBLE_HOST_KEY_CHECKING=False ansible-playbook -i hosts.ini playbook.yml
ansible-playbook -i hosts.ini playbook.yml \
-e "timezone=Europe/Berlin" \
-e "swap_size_mb=4096"
ansible-playbook -i hosts.ini playbook.yml --check --diff
ansible-playbook -i hosts.ini playbook.yml --limit web
Complete playbook for Kamal deployment servers (based on kamal-ansible-manager):
---
- name: Prepare server for Kamal deployment
hosts: web
become: true
vars:
swap_file_size_mb: "2048"
timezone: "UTC"
ufw_allowed_ports: [22, 80, 443]
roles:
- role: geerlingguy.swap
when: ansible_swaptotal_mb < 1
tasks:
# System updates
- name: Update and upgrade packages
ansible.builtin.apt:
update_cache: true
upgrade: dist
# Remove bloat
- name: Remove snapd
ansible.builtin.apt:
name: snapd
state: absent
purge: true
ignore_errors: true
# Essential packages
- name: Install required packages
ansible.builtin.apt:
name: [curl, ca-certificates, fail2ban, ufw, ntp]
state: present
# Docker
- name: Install Docker
ansible.builtin.shell: curl -fsSL https://get.docker.com | sh
args:
creates: /usr/bin/docker
- name: Enable Docker
ansible.builtin.systemd:
name: docker
state: started
enabled: true
# Security
- name: Configure fail2ban
ansible.builtin.copy:
dest: /etc/fail2ban/jail.local
content: |
[sshd]
enabled = true
maxretry = 5
bantime = 3600
mode: "0644"
notify: Restart fail2ban
- name: Configure UFW
community.general.ufw:
rule: allow
port: "{{ item }}"
proto: tcp
loop: "{{ ufw_allowed_ports }}"
- name: Enable UFW
community.general.ufw:
state: enabled
policy: deny
direction: incoming
# SSH hardening
- name: Harden SSH
ansible.builtin.lineinfile:
path: /etc/ssh/sshd_config
regexp: "{{ item.regexp }}"
line: "{{ item.line }}"
loop:
- { regexp: "^#?PasswordAuthentication", line: "PasswordAuthentication no" }
- { regexp: "^#?PermitRootLogin", line: "PermitRootLogin prohibit-password" }
notify: Restart ssh
# Performance
- name: Tune kernel
ansible.posix.sysctl:
name: "{{ item.name }}"
value: "{{ item.value }}"
reload: true
loop:
- { name: vm.swappiness, value: "10" }
- { name: net.core.somaxconn, value: "65535" }
- name: Set timezone
community.general.timezone:
name: "{{ timezone }}"
handlers:
- name: Restart fail2ban
ansible.builtin.systemd:
name: fail2ban
state: restarted
- name: Restart ssh
ansible.builtin.systemd:
name: ssh
state: restarted
#!/usr/bin/env bash
# infra/bin/provision
# 1. Terraform creates server
cd infra && tofu apply
SERVER_IP=$(tofu output -raw server_ip)
# 2. Wait for SSH
until ssh -o ConnectTimeout=5 root@$SERVER_IP true 2>/dev/null; do
sleep 5
done
# 3. Generate inventory
echo "[web]\n$SERVER_IP ansible_user=root" > ansible/hosts.ini
# 4. Run Ansible
cd ansible
ansible-galaxy install -r requirements.yml
ANSIBLE_HOST_KEY_CHECKING=False ansible-playbook -i hosts.ini playbook.yml
# 5. Kamal bootstrap
cd ../..
bundle exec kamal server bootstrap
| Issue | Cause | Fix |
|---|---|---|
ssh: connect refused | Server not ready | Wait or check firewall |
Permission denied | Wrong SSH key | Specify with -i |
sudo: password required | User needs NOPASSWD | Use become_method: sudo |
| Handler not running | Task didn't change | Use changed_when: true |
| Module not found | Missing collection | Install from requirements.yml |