Ansible, sunuculara ajan (agent) kurmadan çalışan bir yapılandırma yönetimi (configuration management) aracıdır: sunucularınızın olması gereken durumunu YAML ile tarif edersiniz, Ansible SSH ile bağlanır ve yalnızca farklı olanı değiştirir. Bu rehberde AlmaLinux ve Ubuntu sunuculardan oluşan küçük, karma bir filo için baştan sona çalışan bir düzen kuruyoruz: kurulum, envanter, ilk gerçek playbook, roller, Ansible Vault, CI, hız ayarları ve sorun giderme. Komutlar ve sürümler Ekim 2026'da ansible-core 2.21 ve Ansible 14 paketiyle kontrol edildi, örnek playbook'lar da --syntax-check ve ansible-lint denetiminden geçiyor. Yine de üretimden önce bir test sunucusunda deneyin.
Ansible nedir, bir çalıştırma nasıl işler?
Ansible tek bir makinede, kontrol düğümünde (control node) çalışır; bu bir dizüstü bilgisayar, bastion sunucusu ya da CI çalıştırıcısı olabilir. Değişiklikleri yönetilen düğümlere (managed node) kendisi gönderir (push) ve onlara sürekli çalışan bir servis kurmaz. Her görev için sistemdeki OpenSSH istemcisiyle bağlanır, küçük bir Python programını (modülü) sunucuya kopyalar, çalıştırır, JSON sonucunu okur ve dosyayı siler. Yönetilen düğümde SSH erişimi ve ansible-core 2.21 için Python 3.9-3.14 yeterlidir; AlmaLinux 9 ve 10 ile Ubuntu 22.04 ve 24.04 bu aralıkta bir Python ile gelir. AlmaLinux 8 ise istisna: dnf'in Python kütüphanesi (python3-dnf) yalnızca sistemdeki Python 3.6 için paketlenmiş, ansible-core ise 2.17'den beri yönetilen düğümde Python 3.6'yı desteklemiyor; yani yeni bir Python kurmak paket yönetimini çalıştırmaya yetmez. EL8 sunucuları ayrı ve daha eski bir ansible-core ortamından (upstream desteği bitmiş 2.16) yönetin ya da AlmaLinux 9 veya 10'a geçişlerini planlayın.Modüllerin çoğu idempotent çalışır, yani tekrar tekrar çalıştırıldığında aynı sonucu verir: mevcut durumu istenen durumla karşılaştırır ve yalnızca fark varsa işlem yapar. Doğru yazılmış bir playbook'u ikinci kez çalıştırdığınızda görevler changed yerine ok döner. Tekrar çalıştırmayı güvenli kılan ve Ansible'ı bir klasör dolusu shell betiğinden ayıran asıl özellik budur. Bunun istisnası command ve shell modülleridir, çünkü Ansible herhangi bir komutun neyi değiştirdiğini bilemez.
| Kavram | Anlamı |
|---|---|
| Kontrol düğümü (control node) | Ansible'ın çalıştığı makine: dizüstü, bastion sunucusu, CI çalıştırıcısı |
| Yönetilen düğüm (managed node) | Ansible'ın yapılandırdığı sunucu (SSH + Python) |
| Envanter (inventory) | Sunucular, gruplar ve bunlara ait değişkenler |
| Modül | Tek bir iş birimi, örneğin ansible.builtin.user |
| Görev (task) / play / playbook | Tek modül çağrısı / bir sunucu grubu için görevler / play'lerden oluşan YAML dosyası |
| Handler | Yalnızca bir değişiklik bildirildiğinde çalışan görev |
| Rol (role) | Görev, handler, şablon ve varsayılan değerlerden oluşan yeniden kullanılabilir paket |
| Koleksiyon (collection) | Modül, eklenti ve rol paketi, örneğin community.general |
| Fact'ler | Her sunucudan toplanan bilgiler: işletim sistemi ailesi, IP adresleri, bellek |
Ansible'ın yeri: Terraform ya da OpenTofu sunucuları, ağları ve DNS kayıtlarını oluşturur; Ansible ise sunucuların içinde çalışan yazılımı yapılandırır. Birçok ekip ikisini birlikte kullanır. Bu seçimi Terraform, Pulumi ve Ansible karşılaştırmamızda ele aldık, burada tekrarlamıyoruz.
Kontrol düğümüne Ansible kurulumu
Kontrol düğümü Linux, macOS ya da bir BSD olabilir; Windows doğrudan kontrol düğümü olamaz. Öğrenmek için WSL iş görür; ancak Ansible belgelerindeki Windows rehberine göre WSL desteklenmez ve üretim sistemlerinde kullanılmamalıdır. WSL içinde projeyi /mnt/c altında değil, Linux ev dizininizde (~/) tutun: Ansible, herkesin yazabildiği (world-writable) görünen bir dizindeki ansible.cfg dosyasını yok sayar.ansible-core, ansible.builtin modüllerini içeren motordur; ansible paketi buna ansible.posix ve community.general gibi seçilmiş koleksiyonları ekler (Ansible 14, ansible-core 2.21 üzerine kuruludur). ansible-core 2.21 kontrol düğümünde Python 3.12-3.14 ister. Sonraki sürüm 2.22 Kasım 2026 için planlanıyor ve Python 3.13 veya üstünü gerektiriyor (PyPI üzerindeki sürüm adayı bunu şimdiden zorunlu tutuyor). Bu yüzden Python 3.12 kullanan bir kontrol düğümü, upstream bakımı Kasım 2027'ye kadar süren 2.21.x serisinde kalır. pipx, Ansible'ı kendi sanal ortamına (virtual environment) kurar ve sistem Python'una dokunmaz:
Bash:
# Ubuntu 24.04 (Python 3.12)
sudo apt update && sudo apt install -y pipx
pipx ensurepath # ardından yeni bir terminal açın
pipx install --include-deps ansible
# AlmaLinux 9 (sistem Python'u 3.9, bu yüzden 3.12 paketini kullanın)
sudo dnf install -y python3.12 python3.12-pip
python3.12 -m pip install --user pipx
python3.12 -m pipx ensurepath
pipx install --include-deps ansible
# macOS
brew install pipx && pipx ensurepath
pipx install --include-deps ansible
ansible --version # ilk satır: ansible [core 2.21.x]
pipx upgrade --include-injected ansible
--include-deps şart, çünkü komutlar ansible-core bağımlılığından gelir; bu seçenek olmadan pipx, pakete bağlı uygulama bulunmadığını ("No apps associated with package") bildirir. Yalnızca motoru istiyorsanız ansible-core kurup gereken koleksiyonları bir requirements.yml dosyasından yükleyin (aşağıda roller bölümünde). Dağıtım paketleri işletim sistemiyle birlikte güncellenir ama sürüm olarak geriden gelir:
| Kaynak (Ekim 2026'da kontrol edildi) | Sürüm |
|---|---|
| Ubuntu 24.04, apt | ansible 9.2.0, ansible-core 2.16.3 üzerinde |
| Ubuntu 26.04, apt | ansible 13.1.0, ansible-core 2.20.1 üzerinde |
| Ansible PPA (ppa:ansible/ansible), Ubuntu 24.04 | ansible 14.5.0, ansible-core 2.21.5 üzerinde |
| AlmaLinux 9, AppStream | ansible-core 2.14.18 |
| AlmaLinux 10, AppStream | ansible-core 2.16.16 |
| PyPI, pipx ile | ansible 14.5.0, ansible-core 2.21.5 üzerinde |
ansible-core 2.14 ve 2.16 artık upstream'de desteklenmiyor; bu sürümlerin düzeltmeleri dağıtımın bakımına bağlı. Güncel sürüm serileri ve Python gereksinimleri için sürüm ve bakım sayfasına bakın.
Envanter (inventory): hangi sunucular, hangi gruplar?
Projeye bir ansible.cfg koyun, her komutta -i yazmak zorunda kalmayın. Ansible yalnızca bulduğu ilk yapılandırma dosyasını kullanır; arama sırası şöyledir: ANSIBLE_CONFIG ortam değişkenindeki yol, ./ansible.cfg, ~/.ansible.cfg, /etc/ansible/ansible.cfg.
INI:
# ansible.cfg
[defaults]
inventory = inventory/hosts.ini
# inventory/hosts.ini (web1: AlmaLinux 9, web2: Ubuntu 24.04)
[web]
web1 ansible_host=203.0.113.10
web2 ansible_host=203.0.113.11
[db]
db1 ansible_host=203.0.113.20 ansible_port=2222
[prod:children]
web
db
Aynı envanterin YAML biçimi; listeleri ve iç içe değerleri daha rahat taşır:
YAML:
all:
children:
web:
hosts:
web1:
ansible_host: 203.0.113.10
web2:
ansible_host: 203.0.113.11
db:
hosts:
db1:
ansible_host: 203.0.113.20
ansible_port: 2222
prod:
children:
web:
db:
Değişkenleri sunucu satırlarına yığmayın. Ansible, envanterin yanındaki group_vars/ ve host_vars/ klasörlerini kendiliğinden yükler: all.yml her sunucuya, web.yml web grubuna, host_vars/web1.yml tek bir sunucuya uygulanır.
YAML:
# inventory/group_vars/all.yml
ansible_user: deploy
ansible_python_interpreter: /usr/bin/python3
admin_user: deploy
admin_pubkey_file: files/deploy_ed25519.pub
En sık kullanacağınız bağlantı değişkenleri ansible_host, ansible_port, ansible_user, ansible_ssh_private_key_file, ansible_become ve ansible_python_interpreter. Bir şey çalıştırmadan önce Ansible'ın ne gördüğünü kontrol edin:
Bash:
ansible-inventory --graph # grup ağacı
ansible-inventory --graph db --vars # tek grup, değişkenleriyle birlikte
ansible-inventory --host web1 # bir sunucunun birleştirilmiş değişkenleri
Bulut ortamlarında amazon.aws.aws_ec2 ya da hetzner.hcloud.hcloud gibi envanter eklentileri (inventory plugin) sunucu listesini sağlayıcının API'sinden okur. Her biri, adını eklentinin tanıdığı küçük bir YAML yapılandırması ister; örneğin hcloud eklentisi adı hcloud.yml ile biten bir dosya bekler.
Ad-hoc komutlar: hızlı kontrol için, yapılandırma için değil
Bash:
ansible all -m ansible.builtin.ping # SSH girişi + Python kontrolü, ICMP değil
ansible web -a "uptime" # varsayılan modül: command
ansible web -b -m ansible.builtin.shell -a "ss -tlnp | grep -E ':(80|443) '"
ansible web -m ansible.builtin.setup -a "filter=ansible_distribution*"
ansible web1 -b -m ansible.builtin.service -a "name=nginx state=restarted"
ping modülü, SSH girişi çalışıyor ve kullanılabilir bir Python bulunuyorsa pong döner; bu yüzden her yeni sunucuda ilk test budur. command modülü programı kabuk (shell) olmadan çalıştırır, dolayısıyla boru (pipe), yönlendirme ve joker karakterler orada çalışmaz; bunlara ihtiyacınız varsa shell kullanın, daha iyisi işe uygun modülü seçin. -b görevi sudo üzerinden çalıştırır, -K sudo parolasını sorar. Ad-hoc komutlar hızlı sorgular ve tek seferlik acil müdahaleler içindir; iki kez yazacağınız her şey playbook'a girmeli, çünkü orada gözden geçirilebilir ve tekrarlanabilir.
AlmaLinux ve Ubuntu için ilk gerçek playbook
Aşağıdaki playbook SSH anahtarı ve parolasız sudo yetkisi olan bir yönetici kullanıcı oluşturur, sshd'yi sıkılaştırır (hardening), SSH, HTTP ve HTTPS trafiğine izin verir (AlmaLinux'ta firewalld, Ubuntu'da ufw ile) ve nginx kurar. Tek dosya iki aileye de ansible_facts['os_family'] üzerinden hizmet eder: bu değer AlmaLinux'ta RedHat, Ubuntu'da Debian'dır. ansible.posix ve community.general, ansible paketiyle birlikte gelir; yalnızca ansible-core kullanıyorsanız önce bunları kurun. Bu adımların neden ilk sırada geldiğini canlı Linux sunucular için sunucu sıkılaştırma yazımızda anlattık.
YAML:
# site.yml
---
- name: Web sunucuları için temel yapılandırma
hosts: web
become: true
vars:
admin_group: "{{ 'wheel' if ansible_facts['os_family'] == 'RedHat' else 'sudo' }}"
ssh_service: "{{ 'sshd' if ansible_facts['os_family'] == 'RedHat' else 'ssh' }}"
tasks:
- name: Yönetici kullanıcıyı oluştur
ansible.builtin.user:
name: "{{ admin_user }}"
groups: "{{ admin_group }}"
append: true
shell: /bin/bash
tags: [users]
- name: Yöneticinin açık anahtarını ekle
ansible.posix.authorized_key:
user: "{{ admin_user }}"
key: "{{ lookup('ansible.builtin.file', admin_pubkey_file) }}"
state: present
tags: [users]
- name: Yöneticiye parolasız sudo ver
ansible.builtin.copy:
dest: "/etc/sudoers.d/{{ admin_user }}"
content: "{{ admin_user }} ALL=(ALL) NOPASSWD: ALL\n"
owner: root
group: root
mode: "0440"
validate: /usr/sbin/visudo -cf %s
tags: [users]
- name: SSH ayarlarını drop-in dosyasıyla sıkılaştır
ansible.builtin.template:
src: templates/sshd-hardening.conf.j2
dest: /etc/ssh/sshd_config.d/00-hardening.conf
owner: root
group: root
mode: "0600"
validate: /usr/sbin/sshd -t -f %s
notify: SSH servisini yeniden yükle
tags: [ssh]
- name: Apt önbelleğini yenile
ansible.builtin.apt:
update_cache: true
cache_valid_time: 3600
when: ansible_facts['os_family'] == 'Debian'
- name: Nginx ve güvenlik duvarı paketini kur
ansible.builtin.package:
name:
- nginx
- "{{ 'firewalld' if ansible_facts['os_family'] == 'RedHat' else 'ufw' }}"
state: present
- name: AlmaLinux güvenlik duvarı (firewalld)
when: ansible_facts['os_family'] == 'RedHat'
tags: [firewall]
block:
- name: Firewalld servisini başlat
ansible.builtin.service:
name: firewalld
state: started
enabled: true
- name: SSH, HTTP ve HTTPS trafiğine izin ver
ansible.posix.firewalld:
service: "{{ item }}"
permanent: true
immediate: true
state: enabled
loop: [ssh, http, https]
- name: Ubuntu güvenlik duvarı (ufw)
when: ansible_facts['os_family'] == 'Debian'
tags: [firewall]
block:
- name: UFW açılmadan önce SSH, HTTP ve HTTPS izni ver
community.general.ufw:
rule: allow
port: "{{ item }}"
proto: tcp
loop: ["22", "80", "443"]
- name: UFW'yi etkinleştir
community.general.ufw:
state: enabled
- name: Nginx servisini başlat
ansible.builtin.service:
name: nginx
state: started
enabled: true
handlers:
- name: SSH servisini yeniden yükle
ansible.builtin.service:
name: "{{ ssh_service }}"
state: reloaded
Code:
# templates/sshd-hardening.conf.j2
# Ansible tarafından yönetilir. Elle yapılan değişikliklerin üzerine yazılır.
PermitRootLogin no
PasswordAuthentication no
KbdInteractiveAuthentication no
PubkeyAuthentication yes
MaxAuthTries 3
X11Forwarding no
- FQCN adları (ansible.builtin.user gibi tam nitelikli koleksiyon adları) hangi modülün çalışacağını kesin olarak belirtir; ansible-lint bunları denetler.
- become: true görevleri sudo ile çalıştırır; sunucuya yine ansible_user olarak bağlanırsınız.
- Fact'ler: ansible_facts['os_family'] biçimini kullanın. Ayrı değişken olarak eklenen ansible_os_family biçimi ansible-core 2.20'den beri kullanımdan kaldırma (deprecation) uyarısı veriyor ve 2.24'te varsayılan olarak kapanacak.
- when, block, loop: when, koşul yanlışsa görevi ya da bloğu atlar; loop bir görevi her öğe için tekrarlar.
- validate, yeni dosya eskisinin yerine geçmeden önce onu visudo -cf veya sshd -t -f ile denetler; böylece bir yazım hatası sizi sunucunun dışında bırakmaz, yalnızca görevi başarısız yapar.
- Handler'lar yalnızca kendilerini bildiren görev changed döndüğünde, play'in o anki bölümünün sonunda (önce pre_tasks, sonra roles ve tasks, sonra post_tasks) ve kaç görev bildirirse bildirsin tek bir kez çalışır.
- tags playbook'un yalnızca bir kısmını çalıştırmanızı sağlar: --tags ssh ya da --skip-tags firewall.
Neden drop-in dosyası? AlmaLinux 9 ve 10 ile Ubuntu, sshd_config dosyasının başına yakın bir yerde /etc/ssh/sshd_config.d/*.conf dosyalarını Include ile içeri alır. sshd çoğu anahtar kelime için okuduğu ilk değeri kullanır ve içeri alınan dosyalar sözlük sırasına (lexical order) göre okunur. Bu yüzden 00-hardening.conf, AlmaLinux kurulum programının parolayla root girişine izin verildiğinde yazdığı 01-permitrootlogin.conf dosyasından ve bulut imajlarının PasswordAuthentication yes ile yazabildiği 50-cloud-init.conf dosyasından önce okunur ve onlara karşı kazanır. Sonuçta geçerli olan (effective) değerleri sudo sshd -T ile görebilirsiniz. Portu bilerek 22'de bıraktık: Ubuntu 22.10 ve sonrasında sshd systemd soket etkinleştirmesiyle (socket activation) başlatılır, bu yüzden port değişikliği ayrıca systemctl daemon-reload ve ssh.socket'in yeniden başlatılmasını gerektirir; AlmaLinux'ta ise önce SELinux'un yeni porta izin vermesi gerekir (semanage port -a -t ssh_port_t -p tcp PORT).
İlk çalıştırma, sağlayıcının verdiği hesapla yapılan bir ön kurulumdur (bootstrap): root ya da imajın varsayılan kullanıcısı, örneğin Ubuntu bulut imajlarında ubuntu. Bu hesabı -e ile verin; envanter değişkenleri komut satırı seçeneklerinden önceliklidir ve -u, ansible_user=deploy karşısında kaybeder.
Bash:
ansible-playbook site.yml --syntax-check
ansible-playbook site.yml -e ansible_user=root --limit web1 # tek sunucuda ön kurulum
ssh [email protected] # ilk oturumu kapatmadan, ikinci bir terminalden
ansible-playbook site.yml --check --diff # sonraki her değişiklikte önce kuru çalıştırma
ansible-playbook site.yml # sonra uygulayın; ansible_user=deploy group_vars'tan gelir
Yeni bir sunucunun ilk çalıştırmasında --check kullanmayın: kullanıcı görevi deploy kullanıcısını yalnızca kâğıt üzerinde oluşturur, bu yüzden anahtar görevi "Either user must exist or you must provide full path to key file in check mode" hatasıyla durur; henüz kurulmamış bir şeye dayanan başka görevler de aynı şekilde takılabilir. Sunucu bir kez kurulduktan sonra --check --diff her değişikliğin doğru ilk adımıdır. Yeni kullanıcıyla (deploy) giriş yapabildiğinizi görene kadar ilk oturumu açık tutun. ufw kuralları ufw etkinleştirilmeden önce eklenir; firewalld'nin varsayılan public bölgesi (zone) SSH trafiğine zaten izin verir. SSH başka bir portta dinliyorsa o portu, güvenlik duvarı devreye girmeden önce açın.
Playbook'ları güvenli çalıştırmak
- --syntax-check, --list-tasks, --list-hosts yapı hatalarını yakalar ve neyin nerede çalışacağını gösterir.
- --check --diff farkları gösteren bir kuru çalıştırmadır (dry run). Check mode'u desteklemeyen modüller bu modda atlanır, önceki bir değişikliğe bağlı görevler hata verebilir; sonucu kesin kanıt değil, güçlü bir ipucu sayın.
- --limit web1 değişikliği önce bir kanarya sunucuya gönderir.
- serial play'i gruplar hâlinde yürütür (1, "30%" ya da [1, 5, "25%"]). Varsayılan olarak play yalnızca bir gruptaki sunucuların tamamı başarısız olduğunda durur; dağıtımı daha erken durdurmak için max_fail_percentage ayarlayın (0, ilk hatada durdurur).
- changed_when ve failed_when command görevlerini dürüst tutar; örnekler aşağıda.
- Bir görevi silmek hiçbir şeyi geri almaz: görevi kaldırma biçimine çevirip bir kez daha çalıştırın (paket, kullanıcı ve dosyalar için state: absent, firewalld servisi için state: disabled, ufw kuralı için delete: true). ufw modülünün belgeleri, allow görevini yalnızca silmenin portu açık bırakabileceği konusunda uyarır.
YAML:
- name: Nginx yapılandırmasını doğrula
ansible.builtin.command: nginx -t
changed_when: false # kontrol hiçbir şeyi değiştirmez
- name: Eski cron kaydını ara
ansible.builtin.command: grep -q legacy-backup /etc/crontab
register: legacy
changed_when: false
failed_when: legacy.rc > 1 # 0 = bulundu, 1 = bulunamadı, 2 = hata
- name: Django migration'larını uygula
ansible.builtin.command:
cmd: .venv/bin/python manage.py migrate --noinput
chdir: /srv/app
register: migrate
changed_when: "'No migrations to apply' not in migrate.stdout"
Roller ve koleksiyonlar
Playbook büyüdüğünde ansible-galaxy role init roles/baseline komutu iskeleti oluşturur: defaults/, files/, handlers/, meta/, tasks/, templates/, tests/, vars/ ve README.md. Görevleri, handler'ı, şablonu ve anahtarı bu yapıya taşıyın; site.yml de hosts, become: true ve roles altındaki baseline rolünden oluşan kısa bir play'e iner. Ayarlanabilir değerleri ve platform sabitlerini ayrı tutun:
YAML:
# roles/baseline/defaults/main.yml : kullanıcının değiştirebileceği ayarlar
baseline_admin_user: deploy
baseline_admin_pubkey_file: deploy_ed25519.pub
baseline_passwordless_sudo: true
# roles/baseline/vars/RedHat.yml : platform sabitleri
baseline_admin_group: wheel
baseline_ssh_service: sshd
baseline_firewall_package: firewalld
# roles/baseline/vars/Debian.yml
baseline_admin_group: sudo
baseline_ssh_service: ssh
baseline_firewall_package: ufw
# roles/baseline/tasks/main.yml içindeki ilk görev
- name: İşletim sistemi ailesinin değişkenlerini yükle
ansible.builtin.include_vars: "{{ ansible_facts['os_family'] }}.yml"
Gerçek hatalara yol açan öncelik (precedence) kuralları şunlar (tam liste değişkenler rehberinde):
- Rol varsayılanları (defaults) sıralamanın en altına yakındır; envanterdeki group_vars, host_vars ve play değişkenleri onları ezer. Ayarlanabilir değerlerin yeri burasıdır.
- Rolün vars dosyaları ve include_vars, envanter değişkenlerinden önceliklidir; buraya yazılan bir değer group_vars ile değiştirilemez. "Ansible group_vars dosyamı görmüyor" şikâyetinin klasik nedeni budur. Burada yalnızca sabitleri tutun.
- Ekstra değişkenler (-e) her zaman kazanır.
- Rol değişkenlerine rol adını önek olarak verin (baseline_...); ansible-lint'in var-naming kuralı bunu denetler.
Dış koleksiyonlar ve roller requirements.yml dosyasına yazılır:
YAML:
# requirements.yml
collections:
- name: ansible.posix
version: ">=2.2.0,<3.0.0"
- name: community.general
version: ">=13.0.0,<14.0.0"
roles:
- name: company_nginx
src: git+https://git.example.com/infra/ansible-role-nginx.git
version: v1.4.0
ansible-galaxy install -r requirements.yml iki bölümü de kurar; ansible-galaxy collection list hangi sürümlerin etkin olduğunu gösterir. Dosyayı depoya işleyin (commit) ki dizüstü bilgisayarlar ve CI aynı sürüm aralıklarından kurulum yapsın; her çalıştırmada birebir aynı kod gerekiyorsa sürümleri tam olarak sabitleyin.
Ansible Vault ile gizli bilgiler
Vault, tek tek değerleri ya da dosyaların tamamını AES256 ile şifreler; böylece bu değerler git deposunda durabilir. Vault kimliği (vault ID) denen prod gibi bir etiket, Ansible'a hangi parolayı kullanacağını söyler. Parolayı deponun dışında tutun:
Bash:
mkdir -p ~/.ansible && install -m 600 /dev/null ~/.ansible/vault-pass-prod
# parolayı bu dosyaya yazın, ardından tek bir değeri şifreleyin:
ansible-vault encrypt_string --vault-id prod@~/.ansible/vault-pass-prod --prompt
# Variable name (enter for no name): vault_db_password
# String to encrypt (hidden):
--prompt gizli değeri ekranda göstermeden okur, böylece değer kabuk geçmişine (shell history) düşmez. Ardından şifresiz bir değişkeni şifreli olana yönlendirin; Ansible belgelerinin önerdiği vars/vault düzeni budur: değişken adları grep ile aranabilir kalır, değerler şifreli kalır.
YAML:
# inventory/group_vars/web/vault.yml
vault_db_password: !vault |
$ANSIBLE_VAULT;1.2;AES256;prod
37613230326661303866366665386439346435646163316435396238663261323065363366386131
...
# inventory/group_vars/web/vars.yml
db_password: "{{ vault_db_password }}"
Parola dosyasını bir kez ansible.cfg içinde belirtin, diğer komutlar onu kendiliğinden bulur:
Bash:
# ansible.cfg, [defaults] bölümü:
# vault_identity_list = prod@~/.ansible/vault-pass-prod
ansible-vault create secrets.yml
ansible-vault edit secrets.yml
ansible-vault view secrets.yml
ansible-vault rekey --new-vault-id prod@prompt secrets.yml # parolayı değiştir
ansible-playbook site.yml # şifre otomatik çözülür
- encrypt_string ile şifrelenmiş tekil değerler rekey ile yeni parolaya taşınamaz; şifrelenmiş dosyalar taşınabilir.
- Gizli bilgiyle çalışan görevlerde no_log: true, gizli bilgi içeren şablonlarda diff: false kullanın; aksi hâlde --diff gizli değeri ekrana basar.
- Parola dosyasını asla depoya işlemeyin.
- Kısa ömürlü ya da birden çok sistemin paylaştığı gizli bilgiler için harici bir gizli bilgi yöneticisi (secret store) daha uygundur; örneğin community.hashi_vault koleksiyonu üzerinden HashiCorp Vault. Bu araçlarla ilgili sorular için bu bölümdeki Vault ve Gizli Anahtarlar (Secrets) alt forumlarını kullanabilirsiniz.
Lint, test ve CI
- ansible-lint iyi uygulama kurallarını denetler: FQCN kullanımı, dosya izinleri, changed_when içermeyen command görevleri ve yamllint üzerinden YAML biçimi; bu yüzden ayrıca yamllint çalıştırmak isteğe bağlıdır. Profiller sıkılığı min, basic, moderate, safety, shared ve production sırasıyla artırır; ansible-lint --profile=basic ile başlayın. Bu yazıdaki örnekler ansible-lint 26.10.1 ile production profilinden geçiyor.
- Molecule bir rolü geçici makinelerde, genellikle konteynerlerde test eder. molecule test komutu, rolü ikinci kez uygulayan ve hâlâ changed dönen bir görev varsa başarısız olan bir idempotence adımı içerir.
- Linter'ı pipx install ansible-lint ile kurun. ansible-dev-tools paketi ansible-lint, Molecule ve ansible-navigator dâhil araç setinin tamamını içerir; belgeleri kurulum için pip kullandığından paketi bir sanal ortama kurun.
Playbook çalıştıran bir CI işi, CI sisteminin gizli bilgi deposundan (secret store) üç değer ister: ayrı bir deploy kullanıcısının SSH özel anahtarı, sabitlenmiş known_hosts satırları ve vault parolası. Ansible çalıştırılabilir bir parola dosyasını çalıştırıp çıktısını okuduğu için parola, küçük bir betik aracılığıyla ortam değişkeninden gelebilir:
Bash:
#!/bin/sh
# vault-pass.sh: çalıştırma izniyle (chmod +x) depoya işleyin; gizli bilgi içermez
printf '%s\n' "$ANSIBLE_VAULT_PASSWORD"
Bash:
# CI işinin adımları (GitHub Actions, GitLab CI, Jenkins); gizli bilgiler ortam değişkeni olarak gelir
install -d -m 700 ~/.ssh
printf '%s\n' "$DEPLOY_SSH_KEY" > ~/.ssh/id_ed25519 && chmod 600 ~/.ssh/id_ed25519
printf '%s\n' "$KNOWN_HOSTS" > ~/.ssh/known_hosts # bir kez ssh-keyscan ile toplandı, parmak izleri doğrulandı
export ANSIBLE_VAULT_IDENTITY_LIST=prod@./vault-pass.sh # ansible.cfg içindeki dizüstü yolunu geçersiz kılar
pipx install --include-deps ansible && pipx install ansible-lint
ansible-galaxy install -r requirements.yml
ansible-lint
ansible-playbook site.yml --check --diff # bu deponun dallarından gelen pull request'ler
ansible-playbook site.yml # main dalı, onaydan sonra
ANSIBLE_VAULT_IDENTITY_LIST, ansible.cfg'den önceliklidir ve ansible-lint de onu okur; böylece aynı depo hem dizüstü bilgisayarda hem CI'da çalışır. Betiğin çalıştırma izni kaybolursa Ansible betik metnini parola sanar ve şifre çözme başarısız olur. Pipeline yeşile dönsün diye host_key_checking = False ayarlamayın: sabitlenmiş sunucu anahtarları, pipeline'ın gizli bilgileri yanlış makineye göndermesini engeller. Fork'lardan gelen pull request'leri de gizli bilgilerden uzak tutun: checkout edilen koddaki bir ansible.cfg çalıştırılabilir bir parola dosyasını gösterebilir ve Ansible ya da ansible-lint o dosyadaki kodu çalıştırır. Gizli bilgilerin kapsamını ve üretim onay kapılarını uzun ömürlü CI/CD pipeline'ları yazımızda ele aldık.
Çalıştırmaları hızlandırmak
Ansible varsayılan olarak aynı anda 5 sunucuyla çalışır ve her play'in başında tüm fact'leri toplar. Ayarlanmış bir ansible.cfg örneği:
INI:
[defaults]
inventory = inventory/hosts.ini
forks = 20
gathering = smart
fact_caching = ansible.builtin.jsonfile
fact_caching_connection = ~/.cache/ansible/facts
fact_caching_timeout = 7200
callbacks_enabled = ansible.posix.profile_tasks
[ssh_connection]
pipelining = True
ssh_args = -C -o ControlMaster=auto -o ControlPersist=10m
- forks: paralel işlenen sunucu sayısı. Her fork ayrı bir süreçtir; tavanı kontrol düğümünün işlemcisi ve belleği belirler.
- pipelining, modülleri önce dosya kopyalamadan açık SSH oturumu üzerinden çalıştırır ve görev başına SSH işlemi sayısını azaltır. sudo'nun requiretty seçeneğiyle çakıştığı için varsayılan olarak kapalıdır; açmadan önce sudo grep -r requiretty /etc/sudoers /etc/sudoers.d ile kontrol edin.
- ControlPersist her sunucu için tek bir bağlantıyı yeniden kullanır. Varsayılan ssh_args bağlantıyı zaten 60 saniye açık tutar; kendi ssh_args değeriniz varsayılanın tamamının yerine geçer, bu yüzden ControlMaster=auto ayarını içinde bırakın.
- Fact'ler: gathering = smart ve bir önbellek, aynı bilgilerin tekrar tekrar toplanmasını önler. Fact okumayan play'lerde gather_facts: false kullanın; gather_subset: ["!all"] yalnızca asgari kümeyi toplar ve os_family bu kümeye dâhildir.
- profile_tasks her görevin ne kadar sürdüğünü yazar; böylece tahmin yürütmek yerine gerçekten yavaş olan görevleri iyileştirirsiniz.
- Bir bastion sunucusu üzerinden çalışırken her fork bastion'a kendi bağlantısını açar; sshd'nin MaxStartups ayarı (varsayılan 10:30:100), kimliği doğrulanmamış 10 bağlantı beklemedeyken yeni bağlantıların bir kısmını reddetmeye başlar. Bastion'da MaxStartups değerini yükseltin ya da forks değerini ölçülü tutun.
Her ayarın kazancı ağ gecikmesine, sunucu sayısına ve görevlerin kendisine bağlıdır; bir değişiklikten önce ve sonra profile_tasks ile ölçün.
Sık karşılaşılan hatalar ve çözümleri
| Mesaj veya belirti | Olası neden | Çözüm |
|---|---|---|
| UNREACHABLE ... Permission denied (publickey) | Yanlış kullanıcı veya anahtar ya da anahtar sunucuda yok | ssh -i anahtar kullanici@sunucu ile elle deneyin; ansible_user ve ansible_ssh_private_key_file değerlerini kontrol edin; -vvvv ile tekrar çalıştırın |
| Host key verification failed | İlk bağlantı ya da sunucu yeniden kuruldu | Parmak izini karşılaştırın, sonra ekleyin (ssh-keyscan) ya da eskisini silin (ssh-keygen -R); sunucu anahtarı denetimini genel olarak kapatmayın |
| Missing sudo password | become bir parola istiyor ama Ansible'a verilmedi | -K ekleyin, otomasyon kullanıcısına NOPASSWD sudo verin ya da ansible_become_password değerini Vault'ta tutun |
| Host 'web1' is using the discovered Python interpreter at ... | Yorumlayıcı keşfi (interpreter discovery) uyarısı | group_vars içinde ansible_python_interpreter: /usr/bin/python3 tanımlayın ya da interpreter_python = auto_silent kullanın |
| couldn't resolve module/action 'community.general.ufw' | Koleksiyon eksik (yalnızca ansible-core kurulu) ya da yazım hatası | ansible-galaxy install -r requirements.yml çalıştırın; ansible-galaxy collection list ile kontrol edin |
| YAML parsing failed: This may be an issue with missing quotes around a template block. | Değer {{ ile başlıyor ve YAML onu sözlük (dictionary) sanıyor | Değerin tamamını tırnak içine alın: msg: "{{ admin_user }}" |
| Conditional result (True) was derived from value of type 'str' ... Conditionals must have a boolean result. | 2.19'dan beri when gerçek bir boolean bekliyor | Testi açıkça yazın: admin_user | length > 0; evet/hayır metinleri için var | bool |
| Either user must exist or you must provide full path to key file in check mode | Kullanıcıyı önceki bir görevin oluşturacağı sunucuda --check | Yeni sunucuda beklenen bir durum; ön kurulumu gerçekten yapın, sonra check mode kullanın |
| Handler hiç çalışmıyor | Görev changed değil ok döndü ya da sonraki bir görev hata verdi ve play handler'a gelmeden durdu | Görev sonucunu kontrol edin; --force-handlers veya force_handlers: true kullanın; meta: flush_handlers handler'ları daha erken çalıştırır |
| The requested handler 'Reload SSHD' was not found ... | notify metni handler adından farklı; ad büyük/küçük harfe duyarlı | Adı birebir kopyalayın ya da listen konularını (topic) kullanın |
| provided hosts list is empty ya da Could not match supplied host pattern | Yanlış envanter yolu ya da herkesin yazabildiği bir dizinde yok sayılan ansible.cfg | ansible --version kullanılan yapılandırma dosyasını, ansible-config dump --only-changed değiştirilmiş ayarları gösterir |
| Gathering Facts aşaması yavaş | Her play'de tüm fact'ler toplanıyor | gather_facts: false, gather_subset ya da fact önbelleği (yukarıya bakın) |
Sık sorulan sorular
Ansible mı, Terraform mu?Terraform (ya da OpenTofu) bulut kaynaklarını oluşturur ve bir state dosyasıyla takip eder; Ansible işletim sistemlerini ve uygulamaları yapılandırır. İkisi birlikte iyi çalışır. Ansible da bulut API'lerini çağırabilir ama state tutmaz; artık tanımlamadığınız kaynakları kaldırmak sizin işinizdir.
Neden düz shell betikleri değil?
Betik komut çalıştırır; Ansible modülleri bir son durum tanımlar ve önce mevcut durumu kontrol eder. Güvenli tekrar çalıştırma, farkları gösteren kuru çalıştırma, tüm sunucular için tek envanter ve paralel çalışma elde edersiniz; sunucu sayısı birkaçı geçtiğinde bunların değeri hissedilir.
Ansible sunucularda ajan (agent) gerektirir mi?
Hayır. Yönetilen düğümlerde SSH erişimi ve ansible-core 2.21 için Python 3.9 veya üstü yeterlidir. Ağ cihazları ayrı bir durumdur: onların modülleri genellikle cihazda Python gerektirmez.
Ansible Windows sunucuları yönetebilir mi?
Evet, yönetilen düğüm olarak: ansible.windows koleksiyonuyla WinRM (winrm veya psrp eklentisi) ya da SSH üzerinden. SSH, ansible-core 2.18'den beri Windows ile gelen OpenSSH kullanılarak resmî olarak destekleniyor; bu pratikte Windows Server 2022 ve sonrası demek. Kontrol düğümü yine Linux, macOS ya da BSD olmalı.
Web arayüzü var mı?
Ticari seçenek Red Hat Ansible Automation Platform'dur. Onun upstream projelerinden biri olan AWX, web arayüzü ve REST API sunar; ancak GitHub'daki README dosyası son sürümün Temmuz 2024'te çıktığını ve büyük bir yeniden yapılanma sürerken yeni sürümlerin durdurulduğunu belirtiyor. MIT lisanslı Semaphore UI bir alternatif; birçok küçük ekip ise playbook'ları doğrudan CI'dan çalıştırıyor.
Sonuç ve sonraki adımlar
Ansible, sunucularınıza değişikliklerin ulaştığı tek yol hâline geldiğinde karşılığını verir. Mantıklı bir sıra şöyle: pipx ile kurun, envanteri yazın ve ansible all -m ansible.builtin.ping komutunun her sunucudan yanıt aldığını görün; temel playbook ile tek bir sunucuda --limit kullanarak ön kurulum yapın, sonraki her değişikliğe --check --diff ile başlayın; playbook'u bir role taşıyıp ansible-lint ekleyin; ardından gizli bilgileri Vault'a alın ve playbook'u CI'ın çalıştırmasına bırakın. Sürümler değiştiğinde başvuru kaynağı resmî kurulum rehberidir. Sunucuların işletimini başkasına bırakmayı tercih ederseniz AIOR'un barındırma ve sunucu hizmetleri VPS, dedicated ve yönetilen sunucuların yanında AlmaLinux ve Ubuntu sunucu yönetimini de kapsıyor. Kendi kurulumunuzla ilgili soruları bu konuya yazabilirsiniz.Ansible is an agentless configuration management tool: you describe the desired state of your servers in YAML, and Ansible connects over SSH and changes only what differs. This tutorial builds a working setup for a small mixed fleet of AlmaLinux and Ubuntu servers, from installation and inventory to a first real playbook, roles, Ansible Vault, CI, speed tuning and troubleshooting. Commands and versions were checked against ansible-core 2.21 and the Ansible 14 package in October 2026, and the example playbooks pass --syntax-check and ansible-lint. Still, try them on a test server before production.
What Ansible is and how a run works
Ansible runs on one machine, the control node (a laptop, bastion host or CI runner), and pushes changes to the managed nodes without installing a daemon on them. For each task it connects with the system's OpenSSH client, copies a small Python program (a module) to the server, runs it, reads the JSON result and deletes it. Managed nodes need only SSH and Python 3.9 to 3.14 for ansible-core 2.21, which AlmaLinux 9 and 10 and Ubuntu 22.04 and 24.04 already have. AlmaLinux 8 is the exception: its dnf Python bindings (python3-dnf) are packaged only for the system Python 3.6, which ansible-core stopped supporting on managed nodes in 2.17, so installing a newer Python does not get package management working. Manage EL8 hosts from a separate, older ansible-core environment (2.16, already end of life upstream) or plan their move to AlmaLinux 9 or 10.Most modules are idempotent: they compare the current state with the requested one and act only on a difference, so a second run of a correct playbook reports ok instead of changed. That makes re-runs safe and is the real difference from a folder of shell scripts. The exceptions are command and shell, since Ansible cannot know what an arbitrary command changes.
| Term | Meaning |
|---|---|
| Control node | Where Ansible runs: laptop, bastion host, CI runner |
| Managed node | A server Ansible configures (SSH + Python) |
| Inventory | Hosts, groups and their variables |
| Module | A unit of work, e.g. ansible.builtin.user |
| Task / play / playbook | One module call / tasks for a host group / a YAML file of plays |
| Handler | A task that runs only when notified by a change |
| Role | Reusable bundle of tasks, handlers, templates and defaults |
| Collection | Package of modules, plugins and roles, e.g. community.general |
| Facts | Data gathered from each host: OS family, IPs, memory |
Where it fits: Terraform or OpenTofu create servers, networks and DNS records, Ansible configures what runs inside the servers, and many teams use both. Our Terraform, Pulumi and Ansible comparison covers that choice, so this guide does not repeat it.
Installing Ansible on the control node
The control node runs Linux, macOS or a BSD; Windows cannot be one. WSL works for learning, but the Windows guide in the Ansible docs says it is not supported and should not be used for production systems. Inside WSL, keep the project in your Linux home directory rather than under /mnt/c, because Ansible ignores an ansible.cfg in a directory that looks world-writable.ansible-core is the engine with the ansible.builtin modules; the ansible package adds curated collections such as ansible.posix and community.general (Ansible 14 is built on ansible-core 2.21). ansible-core 2.21 needs Python 3.12 to 3.14 on the control node. Its successor, 2.22, is planned for November 2026 and needs Python 3.13 or newer (its release candidate on PyPI already enforces this), so a Python 3.12 control node stays on 2.21.x, maintained upstream until November 2027. pipx keeps Ansible in its own virtual environment:
Bash:
# Ubuntu 24.04 (Python 3.12)
sudo apt update && sudo apt install -y pipx
pipx ensurepath # then open a new shell
pipx install --include-deps ansible
# AlmaLinux 9 (system Python is 3.9, so use the 3.12 package)
sudo dnf install -y python3.12 python3.12-pip
python3.12 -m pip install --user pipx
python3.12 -m pipx ensurepath
pipx install --include-deps ansible
# macOS
brew install pipx && pipx ensurepath
pipx install --include-deps ansible
ansible --version # first line: ansible [core 2.21.x]
pipx upgrade --include-injected ansible
--include-deps is required because the commands come from the ansible-core dependency; without it pipx reports that no apps are associated with the package. For the minimal engine, install ansible-core plus the collections listed in a requirements.yml (see roles below). Distribution packages update with the OS but lag behind:
| Source (checked October 2026) | Version |
|---|---|
| Ubuntu 24.04, apt | ansible 9.2.0 on ansible-core 2.16.3 |
| Ubuntu 26.04, apt | ansible 13.1.0 on ansible-core 2.20.1 |
| Ansible PPA (ppa:ansible/ansible), Ubuntu 24.04 | ansible 14.5.0 on ansible-core 2.21.5 |
| AlmaLinux 9, AppStream | ansible-core 2.14.18 |
| AlmaLinux 10, AppStream | ansible-core 2.16.16 |
| PyPI through pipx | ansible 14.5.0 on ansible-core 2.21.5 |
Upstream no longer supports ansible-core 2.14 and 2.16, so fixes for those depend on the distribution. The release and maintenance page lists current lines and Python requirements.
Inventory: which servers, which groups
Put an ansible.cfg in the project so you can drop -i from every command. Ansible uses only the first config file it finds, in this order: the path in ANSIBLE_CONFIG, ./ansible.cfg, ~/.ansible.cfg, /etc/ansible/ansible.cfg.
INI:
# ansible.cfg
[defaults]
inventory = inventory/hosts.ini
# inventory/hosts.ini (web1: AlmaLinux 9, web2: Ubuntu 24.04)
[web]
web1 ansible_host=203.0.113.10
web2 ansible_host=203.0.113.11
[db]
db1 ansible_host=203.0.113.20 ansible_port=2222
[prod:children]
web
db
The same inventory in YAML, which handles lists and nested values better:
YAML:
all:
children:
web:
hosts:
web1:
ansible_host: 203.0.113.10
web2:
ansible_host: 203.0.113.11
db:
hosts:
db1:
ansible_host: 203.0.113.20
ansible_port: 2222
prod:
children:
web:
db:
Keep variables out of the host lines. Ansible loads group_vars/ and host_vars/ next to the inventory automatically: all.yml applies to every host, web.yml to the web group, host_vars/web1.yml to one host.
YAML:
# inventory/group_vars/all.yml
ansible_user: deploy
ansible_python_interpreter: /usr/bin/python3
admin_user: deploy
admin_pubkey_file: files/deploy_ed25519.pub
The connection variables you will use most are ansible_host, ansible_port, ansible_user, ansible_ssh_private_key_file, ansible_become and ansible_python_interpreter. Check what Ansible sees before running anything:
Bash:
ansible-inventory --graph # group tree
ansible-inventory --graph db --vars # one group with its variables
ansible-inventory --host web1 # merged variables of one host
For cloud fleets, inventory plugins such as amazon.aws.aws_ec2 or hetzner.hcloud.hcloud read the host list from the provider's API. Each takes a small YAML config whose file name the plugin recognises; hcloud expects one ending in hcloud.yml.
Ad-hoc commands: quick checks, not configuration
Bash:
ansible all -m ansible.builtin.ping # SSH login + Python check, not ICMP
ansible web -a "uptime" # default module: command
ansible web -b -m ansible.builtin.shell -a "ss -tlnp | grep -E ':(80|443) '"
ansible web -m ansible.builtin.setup -a "filter=ansible_distribution*"
ansible web1 -b -m ansible.builtin.service -a "name=nginx state=restarted"
ping answers pong when the SSH login works and a usable Python is found, so it is the first test for every new host. command runs a program without a shell, so pipes, redirects and wildcards do not work there; use shell when you need them, or better, a dedicated module. -b runs the task through sudo and -K asks for the sudo password. Ad-hoc commands suit quick queries and one-off emergencies; anything you would type twice belongs in a playbook, where it can be reviewed and repeated.
A first real playbook for AlmaLinux and Ubuntu
This playbook creates an admin user with an SSH key and passwordless sudo, hardens sshd, opens SSH, HTTP and HTTPS (firewalld on AlmaLinux, ufw on Ubuntu) and installs nginx. One file serves both families through ansible_facts['os_family'], which is RedHat on AlmaLinux and Debian on Ubuntu. ansible.posix and community.general ship with the ansible package; with ansible-core alone, install them first. Our server hardening guide for production Linux explains why these steps come first.
YAML:
# site.yml
---
- name: Baseline for web servers
hosts: web
become: true
vars:
admin_group: "{{ 'wheel' if ansible_facts['os_family'] == 'RedHat' else 'sudo' }}"
ssh_service: "{{ 'sshd' if ansible_facts['os_family'] == 'RedHat' else 'ssh' }}"
tasks:
- name: Create the admin user
ansible.builtin.user:
name: "{{ admin_user }}"
groups: "{{ admin_group }}"
append: true
shell: /bin/bash
tags: [users]
- name: Install the admin public key
ansible.posix.authorized_key:
user: "{{ admin_user }}"
key: "{{ lookup('ansible.builtin.file', admin_pubkey_file) }}"
state: present
tags: [users]
- name: Passwordless sudo for the admin user
ansible.builtin.copy:
dest: "/etc/sudoers.d/{{ admin_user }}"
content: "{{ admin_user }} ALL=(ALL) NOPASSWD: ALL\n"
owner: root
group: root
mode: "0440"
validate: /usr/sbin/visudo -cf %s
tags: [users]
- name: Harden sshd with a drop-in file
ansible.builtin.template:
src: templates/sshd-hardening.conf.j2
dest: /etc/ssh/sshd_config.d/00-hardening.conf
owner: root
group: root
mode: "0600"
validate: /usr/sbin/sshd -t -f %s
notify: Reload sshd
tags: [ssh]
- name: Refresh the apt cache
ansible.builtin.apt:
update_cache: true
cache_valid_time: 3600
when: ansible_facts['os_family'] == 'Debian'
- name: Install nginx and the firewall
ansible.builtin.package:
name:
- nginx
- "{{ 'firewalld' if ansible_facts['os_family'] == 'RedHat' else 'ufw' }}"
state: present
- name: Firewall on AlmaLinux (firewalld)
when: ansible_facts['os_family'] == 'RedHat'
tags: [firewall]
block:
- name: Start firewalld
ansible.builtin.service:
name: firewalld
state: started
enabled: true
- name: Allow SSH, HTTP and HTTPS
ansible.posix.firewalld:
service: "{{ item }}"
permanent: true
immediate: true
state: enabled
loop: [ssh, http, https]
- name: Firewall on Ubuntu (ufw)
when: ansible_facts['os_family'] == 'Debian'
tags: [firewall]
block:
- name: Allow SSH, HTTP and HTTPS before enabling ufw
community.general.ufw:
rule: allow
port: "{{ item }}"
proto: tcp
loop: ["22", "80", "443"]
- name: Enable ufw
community.general.ufw:
state: enabled
- name: Start nginx
ansible.builtin.service:
name: nginx
state: started
enabled: true
handlers:
- name: Reload sshd
ansible.builtin.service:
name: "{{ ssh_service }}"
state: reloaded
Code:
# templates/sshd-hardening.conf.j2
# Managed by Ansible. Local edits will be overwritten.
PermitRootLogin no
PasswordAuthentication no
KbdInteractiveAuthentication no
PubkeyAuthentication yes
MaxAuthTries 3
X11Forwarding no
- FQCN names such as ansible.builtin.user say exactly which module runs; ansible-lint checks for them.
- become: true runs the tasks through sudo; you still log in as ansible_user.
- Facts: read ansible_facts['os_family']. The injected ansible_os_family form has raised deprecation warnings since ansible-core 2.20 and is due to be off by default in 2.24.
- when, block, loop: when skips a task or block where the condition is false; loop repeats a task per item.
- validate runs visudo -cf or sshd -t -f on the new file before it replaces the old one, so a typo fails the task instead of locking you out.
- Handlers run only when a notifying task reports changed, at the end of the current section of the play (pre_tasks, then roles and tasks, then post_tasks), and only once however many tasks notified them.
- tags run part of a playbook: --tags ssh or --skip-tags firewall.
Why a drop-in file? AlmaLinux 9 and 10 and Ubuntu include /etc/ssh/sshd_config.d/*.conf near the top of sshd_config, sshd keeps the first value it reads for most keywords, and included files load in lexical order. 00-hardening.conf therefore sorts ahead of the 01-permitrootlogin.conf that the AlmaLinux installer writes when root login with a password is allowed, and of the 50-cloud-init.conf that cloud images may write with PasswordAuthentication yes. sudo sshd -T shows the values that actually apply. The port stays at 22 on purpose: on Ubuntu 22.10 and later sshd is started through systemd socket activation, so a port change also needs systemctl daemon-reload and a restart of ssh.socket, and on AlmaLinux SELinux must allow the new port first (semanage port -a -t ssh_port_t -p tcp PORT).
The first run is a bootstrap with the account your provider gives you: root, or the image's default user such as ubuntu on Ubuntu cloud images. Pass it with -e, because inventory variables outrank command-line options and -u would lose to ansible_user=deploy.
Bash:
ansible-playbook site.yml --syntax-check
ansible-playbook site.yml -e ansible_user=root --limit web1 # bootstrap one host
ssh [email protected] # from a second terminal, before closing the first
ansible-playbook site.yml --check --diff # every later change: dry run first
ansible-playbook site.yml # then apply; ansible_user=deploy comes from group_vars
Skip --check on the very first run of a fresh host: the user task only pretends to create deploy, so the key task stops with "Either user must exist or you must provide full path to key file in check mode", and other tasks that depend on something not yet installed can fail the same way. Once a host is bootstrapped, --check --diff is the right first step for every change. Keep the original session open until a login as deploy works. The ufw rules go in before ufw is enabled, and firewalld's default public zone already allows SSH; if SSH listens on another port, open it before either firewall starts.
Running playbooks safely
- --syntax-check, --list-tasks, --list-hosts catch structural errors and show what would run where.
- --check --diff is a dry run with diffs. Modules without check mode support are skipped, and tasks that depend on an earlier change can fail, so treat it as a strong hint, not proof.
- --limit web1 sends a change to a canary first.
- serial rolls a play through batches (1, "30%" or [1, 5, "25%"]). By default the play stops only when every host in a batch fails; set max_fail_percentage (0 stops at the first failure) to halt the rollout sooner.
- changed_when and failed_when keep command tasks honest, as below.
- Deleting a task undoes nothing: switch it to the removing form and run it once more (state: absent for packages, users and files, state: disabled for a firewalld service, delete: true for a ufw rule). The ufw module docs warn that simply removing an allow task can leave the port open.
YAML:
- name: Validate the nginx configuration
ansible.builtin.command: nginx -t
changed_when: false # a check never changes anything
- name: Look for a legacy cron entry
ansible.builtin.command: grep -q legacy-backup /etc/crontab
register: legacy
changed_when: false
failed_when: legacy.rc > 1 # 0 = found, 1 = not found, 2 = error
- name: Apply Django migrations
ansible.builtin.command:
cmd: .venv/bin/python manage.py migrate --noinput
chdir: /srv/app
register: migrate
changed_when: "'No migrations to apply' not in migrate.stdout"
Roles and collections
When the playbook grows, ansible-galaxy role init roles/baseline creates the skeleton: defaults/, files/, handlers/, meta/, tasks/, templates/, tests/, vars/ and README.md. Move the tasks, handler, template and key into it, and site.yml shrinks to a short play: hosts, become: true and the baseline role under roles. Keep tunables and platform constants apart:
YAML:
# roles/baseline/defaults/main.yml : settings users may override
baseline_admin_user: deploy
baseline_admin_pubkey_file: deploy_ed25519.pub
baseline_passwordless_sudo: true
# roles/baseline/vars/RedHat.yml : platform constants
baseline_admin_group: wheel
baseline_ssh_service: sshd
baseline_firewall_package: firewalld
# roles/baseline/vars/Debian.yml
baseline_admin_group: sudo
baseline_ssh_service: ssh
baseline_firewall_package: ufw
# first task in roles/baseline/tasks/main.yml
- name: Load OS family variables
ansible.builtin.include_vars: "{{ ansible_facts['os_family'] }}.yml"
The precedence rules that cause real bugs (full list in the variables guide):
- Role defaults sit near the bottom; inventory group_vars, host_vars and play vars override them. Tunables belong here.
- Role vars and include_vars outrank inventory variables, so a value there cannot be changed from group_vars: the classic "Ansible ignores my group_vars" bug. Keep only constants there.
- Extra vars (-e) always win.
- Prefix role variables with the role name (baseline_...); ansible-lint's var-naming rule checks this.
External collections and roles go into requirements.yml:
YAML:
# requirements.yml
collections:
- name: ansible.posix
version: ">=2.2.0,<3.0.0"
- name: community.general
version: ">=13.0.0,<14.0.0"
roles:
- name: company_nginx
src: git+https://git.example.com/infra/ansible-role-nginx.git
version: v1.4.0
ansible-galaxy install -r requirements.yml installs both sections, and ansible-galaxy collection list shows which versions are active. Commit the file so laptops and CI install from the same version ranges; pin exact versions when every run must use identical code.
Secrets with Ansible Vault
Vault encrypts values or whole files with AES256 so they can live in git; a vault ID label such as prod tells Ansible which password to use. Keep the password outside the repository:
Bash:
mkdir -p ~/.ansible && install -m 600 /dev/null ~/.ansible/vault-pass-prod
# put the password into that file, then encrypt one value:
ansible-vault encrypt_string --vault-id prod@~/.ansible/vault-pass-prod --prompt
# Variable name (enter for no name): vault_db_password
# String to encrypt (hidden):
--prompt reads the secret without echo, so it stays out of shell history. Then point a plain variable at the encrypted one, the vars/vault pattern the Ansible docs recommend: variable names stay searchable with grep, values stay encrypted.
YAML:
# inventory/group_vars/web/vault.yml
vault_db_password: !vault |
$ANSIBLE_VAULT;1.2;AES256;prod
37613230326661303866366665386439346435646163316435396238663261323065363366386131
...
# inventory/group_vars/web/vars.yml
db_password: "{{ vault_db_password }}"
Name the password file once in ansible.cfg and the other commands find it on their own:
Bash:
# ansible.cfg, [defaults] section:
# vault_identity_list = prod@~/.ansible/vault-pass-prod
ansible-vault create secrets.yml
ansible-vault edit secrets.yml
ansible-vault view secrets.yml
ansible-vault rekey --new-vault-id prod@prompt secrets.yml # change the password
ansible-playbook site.yml # decrypts automatically
- Encrypted strings cannot be rekeyed; encrypted files can.
- Use no_log: true on tasks that handle secrets and diff: false on templates containing them, or --diff will print the secret.
- Never commit the password file.
- Short-lived or shared secrets fit an external store better, for example HashiCorp Vault through the community.hashi_vault collection; the Vault and Secrets sub-forums in this section are the place for questions about those tools.
Linting, testing and CI
- ansible-lint checks best-practice rules: FQCN, file permissions, command tasks without changed_when, and YAML style through yamllint, so a separate yamllint run is optional. Profiles raise strictness from min through basic, moderate, safety and shared to production; start with ansible-lint --profile=basic. The examples here pass the production profile with ansible-lint 26.10.1.
- Molecule tests a role on throwaway instances, usually containers. molecule test includes an idempotence step that applies the role a second time and fails if anything still reports changed.
- Install the linter with pipx install ansible-lint. The ansible-dev-tools package bundles the whole toolset, including ansible-lint, Molecule and ansible-navigator; its documentation installs it with pip, so put it in a virtual environment.
A CI job that runs playbooks needs three values from the CI secret store: the SSH private key of a dedicated deploy user, pinned known_hosts lines and the vault password. The password can arrive as an environment variable through a small script, because Ansible runs an executable password file and reads its output:
Bash:
#!/bin/sh
# vault-pass.sh: commit it with the executable bit (chmod +x); it contains no secret
printf '%s\n' "$ANSIBLE_VAULT_PASSWORD"
Bash:
# CI job steps (GitHub Actions, GitLab CI, Jenkins); secrets arrive as environment variables
install -d -m 700 ~/.ssh
printf '%s\n' "$DEPLOY_SSH_KEY" > ~/.ssh/id_ed25519 && chmod 600 ~/.ssh/id_ed25519
printf '%s\n' "$KNOWN_HOSTS" > ~/.ssh/known_hosts # collected once with ssh-keyscan, fingerprints checked
export ANSIBLE_VAULT_IDENTITY_LIST=prod@./vault-pass.sh # overrides the laptop path in ansible.cfg
pipx install --include-deps ansible && pipx install ansible-lint
ansible-galaxy install -r requirements.yml
ansible-lint
ansible-playbook site.yml --check --diff # pull requests from branches of this repository
ansible-playbook site.yml # main branch, after approval
ANSIBLE_VAULT_IDENTITY_LIST takes precedence over ansible.cfg and ansible-lint reads it too, so one repository works on laptops and in CI. If the script loses its executable bit, Ansible reads the script text as the password and decryption fails. Do not set host_key_checking = False to get a green build: pinned host keys stop a pipeline from sending secrets to the wrong machine. Keep secrets away from fork pull requests as well, because an ansible.cfg in the checkout can point to an executable password file, and Ansible or ansible-lint would run code from it. Our article on CI/CD pipelines that survive covers secret scoping and production approval gates.
Making runs faster
By default Ansible works on 5 hosts at a time and gathers all facts at the start of every play. A tuned ansible.cfg:
INI:
[defaults]
inventory = inventory/hosts.ini
forks = 20
gathering = smart
fact_caching = ansible.builtin.jsonfile
fact_caching_connection = ~/.cache/ansible/facts
fact_caching_timeout = 7200
callbacks_enabled = ansible.posix.profile_tasks
[ssh_connection]
pipelining = True
ssh_args = -C -o ControlMaster=auto -o ControlPersist=10m
- forks: hosts handled in parallel. Each fork is a separate process, so the control node's CPU and memory set the ceiling.
- pipelining runs modules through the open SSH session without copying files first, which cuts SSH operations per task. It is off by default because it conflicts with sudo's requiretty option; check with sudo grep -r requiretty /etc/sudoers /etc/sudoers.d before enabling it.
- ControlPersist reuses one connection per host. The default ssh_args already keep it for 60 seconds; your own ssh_args replaces the whole default, so keep ControlMaster=auto in it.
- Facts: gathering = smart plus a cache skips repeated collection. Set gather_facts: false in plays that never read facts; gather_subset: ["!all"] collects only the minimal set, which still includes os_family.
- profile_tasks prints how long each task took, so you tune the slow ones instead of guessing.
- Through a bastion host, every fork opens its own connection to it, and sshd's MaxStartups (default 10:30:100) starts refusing some unauthenticated connections once 10 are pending; raise MaxStartups on the bastion or keep forks moderate.
The gain from each setting depends on latency, host count and the tasks themselves, so measure with profile_tasks before and after a change.
Troubleshooting common errors
| Message or symptom | Likely cause | Fix |
|---|---|---|
| UNREACHABLE ... Permission denied (publickey) | Wrong user or key, or the key is not on the server | Try ssh -i key user@host by hand; check ansible_user and ansible_ssh_private_key_file; rerun with -vvvv |
| Host key verification failed | First contact, or the server was rebuilt | Compare the fingerprint, then add it (ssh-keyscan) or remove the old one (ssh-keygen -R); do not disable host key checking globally |
| Missing sudo password | become needs a password Ansible was not given | Add -K, give the automation user NOPASSWD sudo, or keep ansible_become_password in Vault |
| Host 'web1' is using the discovered Python interpreter at ... | Interpreter discovery warning | Set ansible_python_interpreter: /usr/bin/python3 in group_vars, or interpreter_python = auto_silent |
| couldn't resolve module/action 'community.general.ufw' | Collection missing (ansible-core only) or a typo | ansible-galaxy install -r requirements.yml; check ansible-galaxy collection list |
| YAML parsing failed: This may be an issue with missing quotes around a template block. | A value starts with {{ and YAML reads it as a dictionary | Quote the whole value: msg: "{{ admin_user }}" |
| Conditional result (True) was derived from value of type 'str' ... Conditionals must have a boolean result. | Since 2.19, when needs a real boolean | Write the test out: admin_user | length > 0, or var | bool for yes/no strings |
| Either user must exist or you must provide full path to key file in check mode | --check on a host where an earlier task would create the user | Expected on a fresh host; bootstrap it for real, then use check mode |
| A handler never runs | The task reported ok, not changed, or a later task failed and the play stopped first | Check the task result; use --force-handlers or force_handlers: true; meta: flush_handlers runs them earlier |
| The requested handler 'Reload SSHD' was not found ... | notify text differs from the handler name, which is case-sensitive | Copy the exact name, or use listen topics |
| provided hosts list is empty, or Could not match supplied host pattern | Wrong inventory path, or ansible.cfg ignored in a world-writable directory | ansible --version shows the config file in use; ansible-config dump --only-changed shows what it set |
| Gathering Facts is slow | Full fact collection in every play | gather_facts: false, gather_subset or fact caching (see above) |
Frequently asked questions
Ansible or Terraform?Terraform (or OpenTofu) creates and tracks cloud resources through a state file; Ansible configures operating systems and applications. They combine well, and although Ansible can call cloud APIs too, it keeps no state, so removing what you no longer declare is your job.
Why not plain shell scripts?
A script runs commands; Ansible modules declare an end state and check it first. You get safe re-runs, dry runs with diffs, one inventory for every host and parallel execution, which starts to matter beyond a handful of servers.
Does Ansible need an agent on the servers?
No. Managed nodes need SSH access and, for ansible-core 2.21, Python 3.9 or newer. Network devices are a separate case: their modules usually need no Python on the device.
Can Ansible manage Windows?
Yes, as managed nodes, with the ansible.windows collection over WinRM (the winrm or psrp plugin) or SSH. SSH has been officially supported since ansible-core 2.18 with the OpenSSH that ships with Windows, which in practice means Windows Server 2022 and later; the control node still has to be Linux, macOS or a BSD.
Is there a web interface?
Red Hat Ansible Automation Platform is the commercial option. AWX, one of its upstream projects, offers a web UI and REST API, but its GitHub README says new releases have been paused since July 2024 during a large refactoring; Semaphore UI is an MIT-licensed alternative, and many small teams simply run playbooks from CI.
Conclusion and next steps
Ansible pays off once it is the only way changes reach your servers. A sensible order: install it with pipx, write the inventory and get ansible all -m ansible.builtin.ping to answer from every host; bootstrap one server with the baseline playbook and --limit, then make --check --diff the first step of every later change; move the playbook into a role and add ansible-lint; then put secrets into Vault and let CI run the playbook. When versions change, the official installation guide is the reference. If you would rather have someone else run the servers, AIOR's hosting and server services include VPS, dedicated and managed servers as well as AlmaLinux and Ubuntu server management. Questions about your own setup are welcome in this thread.
Last edited: