Ansible Hands On
Max Riechelmann
What Ansible is, why teams use it, and the few concepts you need before reading the repo.
A controller-side program that connects over SSH and usually needs no permanent agent on the target host.
Inventory selects hosts, variables shape behavior, playbooks choose roles, and tasks drive the system toward the desired state.
Modules, parameters, Jinja expressions, conditions, loops, variable scopes, handlers, and validation patterns.
Where the docs live, how to inspect module parameters, and which everyday commands matter most.
Your machine / CI runner
-> Ansible is installed here
-> you run: ansible / ansible-playbook
SSH
-> connects to target hosts
Target host
-> usually no Ansible agent needed
-> needs SSH access
-> needs Python for most modules
Without automation, servers drift: one machine gets an extra package, another misses a config, and nobody fully trusts what state production is really in.
SSHing into machines and tweaking them by hand creates undocumented differences over time.
The same code can provision stage, prod, builders, and maintenance hosts in a predictable way.
Changing a package, SSH policy, or runner label becomes a git diff instead of tribal knowledge.
Security updates, full upgrades, hardening, and runner cleanup become repeatable operational playbooks.
Ansible is not a daemon on the target machines. It is a controller-driven tool that connects over SSH, checks state, and applies changes.
That is exactly what this repo does for GitHub runner hosts, DevOps machines, and cloud deployment targets.
The host list and grouping model. It tells Ansible which machines exist and how to reach them.
A YAML entrypoint that says which hosts to target and which roles or tasks to apply there.
A reusable bundle of tasks, defaults, handlers, templates, and files for one area of responsibility.
Input data that changes behavior per environment, group, or host without duplicating task logic.
Running the same playbook again should usually converge on the same state instead of redoing work blindly.
You are not writing shell scripts line by line. You declare what should be true on the host.
The chosen inventory controls hostnames, groups, connection parameters, and environment-specific variables.
Ansible merges global vars, inventory vars, host vars, defaults, and extra vars into one effective configuration.
`package`, `user`, `template`, `systemd`, `copy`, and `authorized_key` are the main building blocks.
Good Ansible tells you what was changed and what was already in the desired state.
- name: Apply security-only system updates
hosts: all
become: true
serial: 1
roles:
- role: security_updates
This snippet is from `playbooks/security-updates.yml` in the repo.
all:
children:
staging:
hosts:
web01:
ansible_host: web01.example.internal
ansible_user: deployuser
ansible_port: 4243
# group_vars/all.yml
artifactory_package_registry_url: "https://artifactory.example.internal:443"
artifactory_package_registry_users:
- worker
# inventories/devops/ci-runner-01/group_vars/all.yml
github_runner_token_slot_count: 16
github_runner_group: "linux-build"
artifactory_package_registry_access: read_write
# inventories/devops/ci-runner-01/host_vars/ci-runner-01.yml
docker_systemd_memory_max: "56G"
github_runner_clean_restart_expected_count: 16
A template is a text file with placeholders and logic. Ansible renders it with Jinja2 and writes the final result to the target host.
Usually stored in `roles/<role>/templates/` and ending in `.j2`.
`{{ variable }}` inserts values, `{% ... %}` adds control flow like `if`, `for`, or `set`.
The controller builds the final text from variables and facts, then copies the rendered file to the host.
Great for MOTD, systemd units, nginx configs, shell profiles, timers, and app config files.
The `.j2` file contains placeholders instead of hardcoded host-specific values.
Templates can use inventory vars, group vars, host vars, role defaults, and facts like `ansible_hostname`.
If the rendered result is identical, the task stays unchanged; if not, handlers can react and restart services.
Keep business logic in variables and tasks; keep templates focused on producing the target file cleanly.
{% set host = ansible_hostname %}
{% set env_label = bashrc_profile_env_label | default('env-unknown') %}
{% set environment = node_environment | default(env_label) %}
{% if 'cloud' in environment | lower %}
{% set location = 'Hetzner' %}
{% else %}
{% set location = 'Toulouse' %}
{% endif %}
{{ box_line('Host: ' ~ host) }}
{{ box_line('Environment: ' ~ environment) }}
{{ box_line('Location: ' ~ location) }}
# roles/github_runner_disk_cleanup/templates/github-runner-disk-cleanup.service.j2
[Service]
Type=oneshot
User=root
Group=root
ExecStart={{ github_runner_disk_cleanup_script_path }}
# roles/ci_devops_01_nginx/templates/00-default-catchall.j2
server {
listen 443 ssl http2 default_server;
server_name _;
return 301 https://links.example.internal$request_uri;
}
`package`, `user`, `file`, `copy`, and `systemd` know what "already correct" means.
The repo still uses `command` where the OS provides no better stateful interface, for example timezone reads or SSH config validation.
Example: the `common` role reads the current timezone first and only changes it when needed.
A deploy workflow can re-run without reinstalling everything blindly or obscuring what really changed.
Now that the operating model is clear, the next step is reading the syntax that actually appears in playbooks and roles.
- name: Ensure worker user exists
ansible.builtin.user:
name: "{{ worker_user_name }}"
shell: "{{ worker_user_shell }}"
create_home: true
state: present
ansible.builtin.package:
name: "{{ common_base_packages }}"
state: present
ansible.builtin.template:
src: motd.j2
dest: /etc/motd
ansible.builtin.systemd:
name: docker
state: started
enabled: true
- name: Prepare Docker and GitHub Actions runner
hosts: all
become: true
serial: 1
vars_files:
- ../group_vars/all.yml
roles:
- role: common
- role: docker
- role: github_runner
- name: Ensure Docker service is enabled and running
ansible.builtin.systemd:
name: docker
enabled: true
state: started
- name: Ensure common base packages are installed
ansible.builtin.package:
name: "{{ common_base_packages }}"
state: present
- name: Ensure worker user exists
ansible.builtin.user:
name: "{{ worker_user_name }}"
shell: "{{ worker_user_shell }}"
create_home: "{{ worker_user_create_home }}"
state: present
artifactory_package_registry_host: "{{ artifactory_package_registry_url
| regex_replace('^https?://', '')
| regex_replace('/.*$', '') }}"
github_runner_token_from_env_single: >-
{{
lookup('env', github_runner_token_env_var)
| default('', true)
}}
- name: Assert expected GitHub runner service count before clean restart
ansible.builtin.assert:
that:
- >-
github_runner_clean_restart_service_units | length
== github_runner_clean_restart_expected_count | int
AllowUsers {{
system_hardening_allowed_users | join(' ')
}}
- name: Generate GitHub runner instances from token list
ansible.builtin.set_fact:
github_runner_generated_instances: >-
{{ github_runner_generated_instances + [generated_runner_instance] }}
vars:
generated_runner_instance_input: "{{ item }}"
generated_runner_instance_token: >-
{{
generated_runner_instance_input.token
if (generated_runner_instance_input is mapping)
else generated_runner_instance_input
}}
generated_runner_instance_name: >-
{{
github_runner_name_prefix
~ '-'
~ ('%02d' | format(generated_runner_instance_sequence | int))
}}
# playbook
vars_files:
- ../group_vars/all.yml
# inventory group vars
inventories/devops/ci-runner-01/group_vars/all.yml
# host-specific vars
inventories/devops/ci-runner-01/host_vars/ci-runner-01.yml
- name: Resolve existing generated runner max sequence
ansible.builtin.set_fact:
github_runner_existing_generated_max_sequence: >-
{{
(github_runner_existing_generated_sequences | max)
if (github_runner_existing_generated_sequences | length > 0)
else 0
}}
- name: Set timezone
ansible.builtin.command: "timedatectl set-timezone {{ common_timezone }}"
when: current_timezone.stdout != common_timezone
- name: Include Darwin node_exporter tasks
ansible.builtin.include_tasks: darwin.yml
when: ansible_system == "Darwin"
- name: Read effective SSH configuration
ansible.builtin.command: "{{ system_hardening_sshd_binary }} -T"
changed_when: false
register: system_hardening_effective_ssh_config
- name: Assert effective SSH hardening policy
ansible.builtin.assert:
that:
- "'passwordauthentication no' in system_hardening_effective_ssh_config.stdout"
docker_extra_users:
- deployuser
- worker
- name: Ensure Docker users are in docker group
ansible.builtin.user:
name: "{{ item }}"
groups: docker
append: true
loop: "{{ docker_extra_users }}"
github_runner_instances_effective:
- name: ci-runner-01-org-01
user: worker
- name: ci-runner-01-org-02
user: worker
- name: Ensure runner users are in docker group
ansible.builtin.user:
name: "{{ item.user | default(github_runner_user) }}"
groups: docker
append: true
loop: "{{ github_runner_instances_effective }}"
loop_control:
label: "{{ item.name | default(inventory_hostname) }}"
- name: Manage configured GitHub runner instances
ansible.builtin.include_tasks: instance.yml
loop: "{{ github_runner_instances_effective }}"
loop_control:
label: "{{ item.name | default(inventory_hostname) }}"
vars:
github_runner_instance: "{{ item }}"
- name: Include Linux node_exporter tasks
ansible.builtin.include_tasks: linux.yml
when: ansible_system == "Linux"
- name: Include Darwin node_exporter tasks
ansible.builtin.include_tasks: darwin.yml
when: ansible_system == "Darwin"
- name: Resolve GitHub runner token from legacy single-token environment input
ansible.builtin.set_fact:
github_runner_token_from_env_single: >-
{{ lookup('env', github_runner_token_env_var) | default('', true) }}
- name: Resolve GitHub runner tokens from multi-token environment input
ansible.builtin.set_fact:
github_runner_tokens_from_env_multi: >-
{{
lookup('env', github_runner_tokens_env_var)
| default('', true)
| regex_findall('[^\\s,]+')
}}
- name: Read current timezone
ansible.builtin.command: timedatectl show -p Timezone --value
register: current_timezone
changed_when: false
- name: Check whether DevOps users already exist
ansible.builtin.command: "getent passwd {{ item.name }}"
register: devops_user_lookup
failed_when: false
changed_when: false
- name: Disable Ubuntu dynamic MOTD snippets except reboot notice
ansible.builtin.file:
path: "/etc/update-motd.d/{{ item }}"
mode: "0000"
loop: >-
{{
lookup('ansible.builtin.fileglob', '/etc/update-motd.d/*', wantlist=True)
| map('basename')
| reject('equalto', '98-reboot-required')
| list
}}
when: ansible_facts['os_family'] == 'Debian'
ignore_errors: true
- name: Write nginx config
ansible.builtin.template:
src: 00-default-catchall.j2
dest: /etc/nginx/sites-enabled/00-default-catchall
notify: Reload nginx
# handlers/main.yml
- name: Reload nginx
ansible.builtin.systemd:
name: nginx
state: reloaded
- name: Ensure managed SSH allowlist is not empty
ansible.builtin.assert:
that:
- system_hardening_allowed_users | length > 0
- system_hardening_service_login_public_keys | length > 0
fail_msg: >-
SSH hardening requires at least one allowed login user
and one service login public key.
Once you can read the YAML, the last basics question is how to inspect modules, syntax, and inventories quickly in real work.
Official docs online:
docs.ansible.com
Module pages:
docs.ansible.com/.../collections/ansible/builtin/template_module.html
docs.ansible.com/.../collections/ansible/builtin/user_module.html
Local quick help:
ansible-doc ansible.builtin.template
ansible-doc ansible.builtin.user
ansible --version
ansible-playbook -i inventories/cloud/stage/hosts.yml \
playbooks/security-updates.yml
ansible-playbook -i inventories/devops/ci-runner-01/hosts.yml \
playbooks/setup-runners.yml
ansible-playbook \
-i inventories/devops/ci-runner-01/hosts.yml \
playbooks/system-hardening.yml \
--extra-vars "ansible_become_pass=..."
How we use Ansible to turn a fresh Ubuntu 24.04 LTS install into a ready-to-work developer machine — Docker, Kubernetes and the full toolchain, from one command.
Every local Ubuntu 24.04 LTS box ends up with the same baseline: Docker, a local Kubernetes cluster, and our standard dev tooling.
One playbook targets your machine and composes the roles that install and configure everything in the right order.
`docker`, `kubernetes`, `common`, and `dev_tools` each own one concern and stay reusable across every machine.
Add your host to the inventory and run one `ansible-playbook` command — against localhost or over SSH.
Instead of following a wiki page and installing things by hand, a fresh Ubuntu 24.04 LTS install is brought into a known, repeatable state.
Docker CE, the Buildx and Compose plugins, and your user added to the `docker` group so no `sudo` is needed for daily work.
A lightweight single-node k3s cluster plus `kubectl` and `helm`, so you can run and test workloads locally.
Timezone, base packages, shell setup, unattended security updates, and SSH configured consistently on every machine.
Git, build-essential, language runtimes and CLIs (`uv`, `node`, `kubectl`, `helm`) that the team needs day to day.
The point: a new machine is productive in minutes, and every machine looks the same.
All you need is a reachable Ubuntu 24.04 LTS machine (or your own localhost) with Python and an admin user.
Ansible connects, gathers facts, and applies each role in order — base system first, then Docker, Kubernetes, and tooling.
Because roles are idempotent, re-running the playbook just reconciles whatever drifted instead of reinstalling everything.
Docker running, a local cluster up, and the same toolchain as every other machine on the team.
- name: Set up local Ubuntu 24.04 developer machine
hosts: workstations
become: true
vars_files:
- group_vars/all.yml
roles:
- role: common
- role: unattended_upgrades
- role: docker
- role: kubernetes
- role: dev_tools
Entry point: `playbooks/workstation.yml`.
Timezone, base packages, shell and MOTD setup, and consistent SSH configuration on every machine.
Adds the official Docker apt repository, installs Docker CE with the Compose and Buildx plugins, and enables the service.
Installs a single-node k3s cluster, drops a usable kubeconfig, and adds `kubectl` and `helm`.
Git, build-essential, and the language runtimes and CLIs the team standardizes on.
Each role is generic; the inventory and variables decide what each machine actually gets.
- name: Add Docker apt repository
ansible.builtin.deb822_repository:
name: docker
uris: "https://download.docker.com/linux/ubuntu"
suites: "noble"
components: stable
signed_by: "https://download.docker.com/linux/ubuntu/gpg"
- name: Install Docker packages
ansible.builtin.apt:
name:
- docker-ce
- docker-ce-cli
- containerd.io
- docker-buildx-plugin
- docker-compose-plugin
state: present
update_cache: true
- name: Add developer to the docker group
ansible.builtin.user:
name: "{{ workstation_user }}"
groups: docker
append: true
- name: Install a single-node k3s cluster
ansible.builtin.shell: |
curl -sfL https://get.k3s.io | sh -s - \
--write-kubeconfig-mode 644
args:
creates: /usr/local/bin/k3s
- name: Make the cluster usable for the developer
ansible.builtin.copy:
src: /etc/rancher/k3s/k3s.yaml
dest: "/home/{{ workstation_user }}/.kube/config"
remote_src: true
owner: "{{ workstation_user }}"
mode: "0600"
- name: Install Helm
ansible.builtin.apt:
deb: "{{ helm_deb_url }}"
state: present
# 1. add your machine to the inventory
# inventories/workstations/hosts.yml
all:
children:
workstations:
hosts:
my-laptop:
ansible_host: 127.0.0.1
ansible_connection: local
workstation_user: "{{ lookup('env', 'USER') }}"
# 2. run the playbook against it
ansible-playbook \
-i inventories/workstations/hosts.yml \
playbooks/workstation.yml \
--ask-become-pass
The inventory and variables express the target: Docker, a local Kubernetes cluster, and the standard toolchain.
The workstation playbook calls roles that install packages, configure the runtime, bring up the cluster, and set up tooling.
Any fresh Ubuntu 24.04 LTS box converges to the same baseline — and re-runs only fix what drifted.
Need a new tool on every box? Add it to a role or variable file and re-run — don't install it by hand.
From here, the best next step is to pick one real change and trace it from inventory to workflow to host.
Max Riechelmann · DevOps Guild