Ansible Basics for Server Configuration
Somewhere around the fifth server that needed the same sudo user, the same sshd hardening, the same chrony config and the same set of packages, copying commands out of my notes stopped being a reasonable way to work. Ansible is the lowest-friction way I have found to fix that: no agent on the managed hosts, just SSH and Python, and playbooks in YAML that read like the runbook they replace. It is also idempotent, so running the same playbook twice does nothing the second time, which makes it safe to use for drift correction as well as initial builds.
Here's the minimum I'd want to have known at the start: install, an inventory for a mix of CentOS and Ubuntu, and a baseline playbook (packages, a user with a key, a templated config, a restart when the file changes).
You need a control machine with Python 3 (a workstation or a small management VM), SSH key access to the targets as a sudo-capable user, and Python on the targets. Ubuntu 18.04/20.04 and CentOS 8 have python3; CentOS 7 has python2, which Ansible 2.9 still supports.
Installing Ansible
The distribution packages are usually behind. I install with pip in a virtualenv so upgrades do not touch the system Python:
python3 -m venv ~/ansible-venv
source ~/ansible-venv/bin/activate
pip install 'ansible==2.9.*'
ansible --version
Ansible 2.9 is the last release before the split into ansible-base and collections in 2.10; it is what most documentation still assumes and the modules below are all built in.
The inventory
Create a project directory and an INI-style inventory:
mkdir -p ~/infra && cd ~/infra
nano inventory.ini
[db]
db01 ansible_host=10.0.1.21
db02 ansible_host=10.0.1.22
[web]
web01 ansible_host=10.0.1.31
web02 ansible_host=10.0.1.32
[centos:children]
db
[ubuntu:children]
web
[all:vars]
ansible_user=deploy
ansible_become=yes
[centos:vars]
ansible_python_interpreter=/usr/bin/python2
Groups can contain groups, and variables can be set per group. Then an ansible.cfg in the same directory so you do not have to pass -i every time:
[defaults]
inventory = inventory.ini
host_key_checking = False
retry_files_enabled = False
forks = 10
Ad-hoc commands to test connectivity
ansible all -m ping
ansible db -m shell -a 'uptime'
ansible all -m setup -a 'filter=ansible_distribution*'
ping here is an Ansible module that confirms SSH and Python work, not ICMP. The setup module gathers facts, and the filtered output shows which hosts report CentOS versus Ubuntu, which the playbook uses below.
The baseline playbook
---
- name: Server baseline
hosts: all
become: yes
vars:
admin_user: ops
admin_key: "{{ lookup('file', '~/.ssh/id_ed25519.pub') }}"
ntp_servers:
- 10.0.1.2
- 10.0.1.3
tasks:
- name: Install baseline packages (CentOS)
yum:
name: [vim-enhanced, htop, chrony, rsync]
state: present
when: ansible_os_family == "RedHat"
- name: Install baseline packages (Ubuntu)
apt:
name: [vim, htop, chrony, rsync]
state: present
update_cache: yes
cache_valid_time: 3600
when: ansible_os_family == "Debian"
- name: Create admin user
user:
name: "{{ admin_user }}"
groups: "{{ 'wheel' if ansible_os_family == 'RedHat' else 'sudo' }}"
append: yes
shell: /bin/bash
- name: Install SSH key for admin user
authorized_key:
user: "{{ admin_user }}"
key: "{{ admin_key }}"
- name: Deploy chrony configuration
template:
src: templates/chrony.conf.j2
dest: "{{ '/etc/chrony.conf' if ansible_os_family == 'RedHat' else '/etc/chrony/chrony.conf' }}"
owner: root
group: root
mode: '0644'
notify: restart chrony
- name: Disable root SSH login
lineinfile:
path: /etc/ssh/sshd_config
regexp: '^#?PermitRootLogin'
line: 'PermitRootLogin no'
validate: '/usr/sbin/sshd -t -f %s'
notify: reload sshd
handlers:
- name: restart chrony
service:
name: "{{ 'chronyd' if ansible_os_family == 'RedHat' else 'chrony' }}"
state: restarted
- name: reload sshd
service:
name: "{{ 'sshd' if ansible_os_family == 'RedHat' else 'ssh' }}"
state: reloaded
Save it as baseline.yml. The template templates/chrony.conf.j2:
{% for s in ntp_servers %}
server {{ s }} iburst
{% endfor %}
driftfile /var/lib/chrony/drift
makestep 1.0 3
rtcsync
Handlers run once at the end of the play and only if a task that notifies them reported a change, so chrony restarts only when its config actually changed. The validate option on lineinfile runs sshd -t against the temporary file before installing it, which prevents a typo from locking you out.
Running it
I always do --check --diff first, even on hosts I know well.
ansible-playbook baseline.yml --check --diff
ansible-playbook baseline.yml
ansible-playbook baseline.yml --limit web01
--check is dry-run mode; with --diff it shows the exact changes to files. Run for real afterwards, then run a second time: the recap should show changed=0 for every host, which is the idempotency check.
Checking the result
ansible all -m shell -a 'id ops && grep ^PermitRootLogin /etc/ssh/sshd_config && chronyc sources | head -4'
Every host should show the user, the no setting, and the internal NTP servers. For secrets such as database passwords, store them in an encrypted file with ansible-vault create group_vars/db/vault.yml and pass --ask-vault-pass at run time rather than putting them in the playbook.
Errors
FAILED! => {"msg": "Missing sudo password"}:becomeneeds a password on that host. Add--ask-become-pass, or configureNOPASSWDin sudoers for the deploy user./usr/bin/python: not foundon Ubuntu hosts: Ansible defaulted to Python 2. Setansible_python_interpreter=/usr/bin/python3for theubuntugroup, or upgrade to a version with interpreter discovery (2.8+ has it, but the CentOS 7 override above disables it for that group only).ERROR! Syntax Error while loading YAMLwith a line and column: almost always indentation or a missing space after a colon.ansible-playbook --syntax-check baseline.ymlgives the location without connecting to anything.- A task shows
changedon every run: the module is not idempotent as written, typically ashellorcommandtask. Addcreates:orchanged_when:, or replace it with a proper module.
Small as it is, this replaced my hand-run checklist. Roles and a Git repo are next on my list.