DevOps Guild

Ansible Hands On

Max Riechelmann

Chapter 1

Ansible Basics

What Ansible is, why teams use it, and the few concepts you need before reading the repo.

What You Should Get From This Chapter

Step 1

What Ansible is operationally

A controller-side program that connects over SSH and usually needs no permanent agent on the target host.

Step 2

How one run is assembled

Inventory selects hosts, variables shape behavior, playbooks choose roles, and tasks drive the system toward the desired state.

Step 3

How to read Ansible YAML

Modules, parameters, Jinja expressions, conditions, loops, variable scopes, handlers, and validation patterns.

Step 4

How to find help quickly

Where the docs live, how to inspect module parameters, and which everyday commands matter most.

Absolute Basics: What Ansible Is

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
  • Ansible is first of all just a program you install on the controller machine, for example your laptop or a CI runner.
  • When you run `ansible` or `ansible-playbook`, that controller connects to target machines over SSH.
  • On the target hosts, you usually do not install a permanent Ansible service or agent.
  • That agentless model is one of the main reasons Ansible is easy to adopt for Linux server automation.

The Problem Ansible Solves

Without automation, servers drift: one machine gets an extra package, another misses a config, and nobody fully trusts what state production is really in.

Drift

Manual changes do not scale

SSHing into machines and tweaking them by hand creates undocumented differences over time.

Repeatability

One definition, many hosts

The same code can provision stage, prod, builders, and maintenance hosts in a predictable way.

Auditability

Infrastructure changes become code reviewable

Changing a package, SSH policy, or runner label becomes a git diff instead of tribal knowledge.

Operations

Maintenance becomes a workflow

Security updates, full upgrades, hardening, and runner cleanup become repeatable operational playbooks.

What Ansible Actually Is

Ansible is not a daemon on the target machines. It is a controller-driven tool that connects over SSH, checks state, and applies changes.

Mental Model

Controller runs `ansible-playbook`
Inventory selects target hosts
Modules execute on hosts via SSH
Tasks compare desired vs actual state
Services/files/packages end in the wanted state

What It Usually Manages

Users and SSH keys
Packages and repositories
Config files and templates
Systemd services and timers
Maintenance and operational tasks

That is exactly what this repo does for GitHub runner hosts, DevOps machines, and cloud deployment targets.

Five Core Terms

1

Inventory

The host list and grouping model. It tells Ansible which machines exist and how to reach them.

2

Playbook

A YAML entrypoint that says which hosts to target and which roles or tasks to apply there.

3

Role

A reusable bundle of tasks, defaults, handlers, templates, and files for one area of responsibility.

4

Variable

Input data that changes behavior per environment, group, or host without duplicating task logic.

5

Idempotency

Running the same playbook again should usually converge on the same state instead of redoing work blindly.

Rule of thumb

Think desired state

You are not writing shell scripts line by line. You declare what should be true on the host.

How A Typical Run Works

1 Pick inventory
2 Load variables
3 Run tasks/roles
Selection

`-i inventories/.../hosts.yml`

The chosen inventory controls hostnames, groups, connection parameters, and environment-specific variables.

Resolution

Facts and vars are combined

Ansible merges global vars, inventory vars, host vars, defaults, and extra vars into one effective configuration.

Execution

Modules check and change state

`package`, `user`, `template`, `systemd`, `copy`, and `authorized_key` are the main building blocks.

Outcome

Changed or already correct

Good Ansible tells you what was changed and what was already in the desired state.

Minimal Playbook Example

- name: Apply security-only system updates
  hosts: all
  become: true
  serial: 1
  roles:
    - role: security_updates
  • `hosts: all` means "every host that exists in the selected inventory".
  • `become: true` elevates where root access is required.
  • `serial: 1` is operationally important: update one machine at a time instead of all at once.
  • The real repo uses small playbooks like this as clean entrypoints and pushes complexity down into roles.

This snippet is from `playbooks/security-updates.yml` in the repo.

Inventory Means "Which Machines, Grouped How?"

all:
  children:
    staging:
      hosts:
        web01:
          ansible_host: web01.example.internal
          ansible_user: deployuser
          ansible_port: 4243
  • The inventory file gives Ansible a host name, SSH target, login user, and optional port.
  • Groups such as `staging`, `devops`, or `ci_runner_01` let playbooks or roles target families of hosts.
  • This repo keeps separate inventories per environment or per individual managed machine.
  • The example below mirrors a real `inventories/cloud/stage/hosts.yml` structure.

Variables Layer On Top Of The Inventory

# 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
  • Global repo defaults live in `group_vars/all.yml`.
  • Inventory-specific group vars describe what one environment or host class should look like.
  • Host vars capture true one-off tuning, such as memory limits or watchdog thresholds on one builder.
  • This layering is one of the core patterns in the repo.

Templates: Variables Become Real Files

A template is a text file with placeholders and logic. Ansible renders it with Jinja2 and writes the final result to the target host.

Input

Template file

Usually stored in `roles/<role>/templates/` and ending in `.j2`.

Engine

Jinja2 syntax

`{{ variable }}` inserts values, `{% ... %}` adds control flow like `if`, `for`, or `set`.

Execution

`ansible.builtin.template` renders it

The controller builds the final text from variables and facts, then copies the rendered file to the host.

Use case

One file pattern, many hosts

Great for MOTD, systemd units, nginx configs, shell profiles, timers, and app config files.

How Template Rendering Works

1 Load vars + facts
2 Render `.j2` file
3 Write final config
Source

Template stays generic

The `.j2` file contains placeholders instead of hardcoded host-specific values.

Context

Variables and facts provide the data

Templates can use inventory vars, group vars, host vars, role defaults, and facts like `ansible_hostname`.

Idempotency

Only changes when output changes

If the rendered result is identical, the task stays unchanged; if not, handlers can react and restart services.

Rule

Template text, not shell logic

Keep business logic in variables and tasks; keep templates focused on producing the target file cleanly.

Template Example From This Repo: MOTD

{% 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) }}
  • This is from `roles/common/templates/motd.j2`.
  • The template uses facts like `ansible_hostname` and variables like `bashrc_profile_env_label`.
  • It computes values first and then emits a rendered MOTD file for the host.
  • This is more maintainable than keeping separate static MOTD files per machine.

More Template Examples From This Repo

# 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;
}
  • Templates in this repo are used for systemd unit files, scripts, timers, nginx sites, plist files, and MOTD.
  • Some templates are nearly static with one inserted variable, others are more dynamic and compute multiple fields.
  • The decision is simple: if file content depends on variables, a template is usually the right tool.

Idempotency In Practice

Good

Use stateful modules

`package`, `user`, `file`, `copy`, and `systemd` know what "already correct" means.

Careful

Use `command` only when needed

The repo still uses `command` where the OS provides no better stateful interface, for example timezone reads or SSH config validation.

Pattern

Check, then act

Example: the `common` role reads the current timezone first and only changes it when needed.

Benefit

Safe repeat execution

A deploy workflow can re-run without reinstalling everything blindly or obscuring what really changed.

Part 3

Reading The YAML

Now that the operating model is clear, the next step is reading the syntax that actually appears in playbooks and roles.

Ansible Task Syntax: The Core Shape

- name: Ensure worker user exists
  ansible.builtin.user:
    name: "{{ worker_user_name }}"
    shell: "{{ worker_user_shell }}"
    create_home: true
    state: present
  • A task usually has a human-readable `name`, one module call, and a parameter block below it.
  • `ansible.builtin.user` is the fully qualified module name: collection `ansible.builtin`, module `user`.
  • `state: present` expresses desired state, which is why this is idempotent.
  • This exact pattern appears all over the repo for users, files, packages, services, and templates.

What `ansible.builtin` Means

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
  • `ansible.builtin` is the default core module collection that ships with Ansible.
  • Writing the fully qualified name is explicit and avoids ambiguity once extra collections are installed.
  • You will also see short names like `package:` or `user:` in many codebases; both refer to the same module if resolution is clear.
  • In this repo, the explicit form is used consistently, which makes the code easier to read and document.

Playbook Syntax: `hosts`, `become`, `serial`, `roles`

- 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
  • `hosts` selects which hosts from the chosen inventory this play should target.
  • `become: true` means tasks in this play run with privilege escalation when needed.
  • `serial: 1` means roll out host by host instead of hitting all targets at once.
  • `roles` is the high-level composition layer: each listed role contributes its tasks, defaults, templates, and handlers.

Task Syntax: Parameters Express Desired State

- 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
  • The module name is followed by a YAML map of parameters.
  • Most Ansible code is just this pattern repeated with different modules.
  • Fields like `state: present`, `state: absent`, `state: started`, and `enabled: true` are classic desired-state parameters.
  • You are describing the end result, not scripting each step manually.

Jinja Syntax: `{{ ... }}` For Values

- 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('/.*$', '') }}"
  • `{{ ... }}` means "evaluate this expression and insert the result here".
  • Simple cases insert one variable, like `{{ worker_user_name }}`.
  • More advanced cases apply filters with `|`, for example `regex_replace` or `default`.
  • This is Jinja expression syntax and it appears in tasks, templates, defaults, and inventory variables.

Jinja Syntax: Filters Like `default`, `length`, `int`, `join`

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(' ')
}}
  • Filters transform values from left to right.
  • `default('', true)` gives a fallback when a variable is unset or empty.
  • `length` counts items, `int` converts to integer, and `join(' ')` merges a list into one string.
  • Understanding filters is essential because a lot of Ansible logic is "small transformations in YAML".

`vars`: Define Variables Close To Usage

- 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))
      }}
  • `vars` creates helper variables scoped close to the task or play where they are needed.
  • This is useful when one expression would otherwise become unreadable.
  • The `github_runner` role uses this pattern heavily to break complex instance-generation logic into understandable pieces.
  • Think of `vars` as local named intermediates.

`vars_files`, `group_vars`, `host_vars`: File-Based Variables

# 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
  • `vars_files` imports variables explicitly from a file.
  • `group_vars` and `host_vars` are special Ansible locations that are loaded automatically for matching groups and hosts.
  • This repo relies heavily on those file-based layers to keep roles reusable and hosts specific.
  • When behavior differs per machine, the first question is usually: "which variable layer should own this?"

`set_fact`: Compute New Variables During A Run

- 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
      }}
  • `set_fact` defines a variable during execution time based on earlier values or task results.
  • It is common when logic needs derived state, such as "the effective token list" or "the maximum existing runner sequence".
  • This is one of the most common Ansible syntax tools once playbooks move beyond very simple tasks.
  • In this repo, `github_runner` uses it extensively.

`when`: Conditional Execution

- 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"
  • `when` guards a task with a boolean condition.
  • It is used for platform branching, host-group branching, safety checks, and idempotent "only if needed" logic.
  • You write the expression directly, not wrapped in `{{ }}`.
  • The condition can reference variables, facts, registered results, and filter expressions.

`register`: Store A Task Result

- 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"
  • `register` stores the full result object of a task.
  • Typical fields are `stdout`, `stderr`, `rc`, `changed`, and module-specific values.
  • That makes later tasks data-driven instead of shell-script-driven.
  • It is especially important with `command`, `find`, `stat`, and validation patterns.

`loop`: Repeat A Task Over A List

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) }}"
  • `loop` runs one task for every item in a list.
  • Inside the task, the current element is available as `item`.
  • The first example loops over a simple string list, so `item` is just `deployuser` or `worker`.
  • The second example loops over a list of dictionaries, so you access fields like `item.name` and `item.user`.
  • Loops keep YAML concise and avoid copying the same task many times.
  • When list items are dictionaries, you can access fields like `item.name`, `item.user`, or `item.path`.

`loop_control`: Improve Loop Output

- 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 }}"
  • `loop_control` customizes how loop execution is displayed or handled.
  • `label` is the most common option because it makes output readable in CI logs.
  • Without it, long dictionaries often clutter the output.
  • This is a small syntax feature, but very useful in real automation.

`include_tasks`: Split Logic Into Smaller Files

- 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"
  • `include_tasks` lets you keep large roles readable by splitting them into smaller task files.
  • It is often paired with `when` so only the relevant branch is loaded.
  • This repo uses it for Linux vs Darwin separation and for per-instance runner management.
  • The result is cleaner than keeping one giant `main.yml` with every branch inline.

`lookup('env', ...)`: Read Environment Variables

- 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,]+')
      }}
  • `lookup()` is how Ansible fetches data from external sources.
  • `lookup('env', ...)` reads environment variables from the controller process that runs Ansible.
  • This is why GitHub Actions can export secrets before calling `ansible-playbook` and roles can consume them safely at runtime.
  • In this repo, runner tokens and registry credentials depend on that pattern.

`changed_when` And `failed_when`: Correct The Task Semantics

- 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
  • `changed_when` overrides Ansible's default changed detection.
  • `changed_when: false` means: even if this task ran successfully, Ansible should report it as "ok" and not as "changed".
  • `failed_when` overrides failure detection.
  • These are especially important for `command` tasks that are used only for probing state.
  • They keep the play output honest and prevent harmless checks from looking like changes or fatal errors.

`ignore_errors`: Continue Despite A Non-Critical Failure

- 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
  • `ignore_errors: true` means a failed task does not abort the play.
  • Use it sparingly, usually only when a task is best-effort and non-critical.
  • The `common` role uses it here because MOTD snippet handling may vary slightly across hosts.
  • If you overuse `ignore_errors`, real problems disappear into noise.

`notify` And `handlers`: React Only On Change

- 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
  • `notify` queues a handler when a task reports `changed`.
  • Handlers run at the end of the play by default and are perfect for restarts or reloads.
  • This avoids unnecessary service bounces when nothing actually changed.
  • The repo already has handler files in roles like nginx- and exporter-related components.

`assert`: Turn Assumptions Into Enforced Checks

- 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.
  • `assert` is a defensive syntax tool for making assumptions explicit.
  • If the condition fails, the play stops with a clear message.
  • This is better than letting a later task fail in a confusing way.
  • In infrastructure code, `assert` is often the difference between safe automation and risky automation.
Part 4

Finding Help

Once you can read the YAML, the last basics question is how to inspect modules, syntax, and inventories quickly in real work.

Where Documentation Lives

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
  • The official online documentation is on `docs.ansible.com`.
  • Every module has its own reference page with synopsis, parameters, examples, notes, and return values.
  • `ansible-doc ...` is the fastest local lookup when you already know the module name.
  • If you are unsure which module to use, the online collection docs and the local `ansible-doc` output are the first places to check.

How To Use Ansible On The Command Line

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=..."
  • Some examples on how to run playbooks from the real repository
  • `ansible_become_pass` is used to pass the sudo password.
Chapter 2

Provisioning Local Machines

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.

What You Should Get From This Chapter

Goal

What a provisioned machine looks like

Every local Ubuntu 24.04 LTS box ends up with the same baseline: Docker, a local Kubernetes cluster, and our standard dev tooling.

Playbook

The single workstation entry point

One playbook targets your machine and composes the roles that install and configure everything in the right order.

Roles

What actually gets installed

`docker`, `kubernetes`, `common`, and `dev_tools` each own one concern and stay reusable across every machine.

Hands-on

How to run it on your own laptop

Add your host to the inventory and run one `ansible-playbook` command — against localhost or over SSH.

The Target: One Command, A Full Dev Machine

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.

Containers

Docker Engine

Docker CE, the Buildx and Compose plugins, and your user added to the `docker` group so no `sudo` is needed for daily work.

Orchestration

Local Kubernetes

A lightweight single-node k3s cluster plus `kubectl` and `helm`, so you can run and test workloads locally.

Baseline

Base system

Timezone, base packages, shell setup, unattended security updates, and SSH configured consistently on every machine.

Toolchain

Developer tooling

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.

How A Setup Run Works

1 Fresh Ubuntu 24.04 LTS
2 Run one playbook
3 Docker + k8s ready
Start

A clean install and SSH access

All you need is a reachable Ubuntu 24.04 LTS machine (or your own localhost) with Python and an admin user.

Run

`ansible-playbook playbooks/workstation.yml`

Ansible connects, gathers facts, and applies each role in order — base system first, then Docker, Kubernetes, and tooling.

Converge

Only missing pieces are changed

Because roles are idempotent, re-running the playbook just reconciles whatever drifted instead of reinstalling everything.

Result

A ready, uniform machine

Docker running, a local cluster up, and the same toolchain as every other machine on the team.

The Workstation Playbook

- 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
  • `hosts: workstations` targets the machines listed in the workstation inventory.
  • `become: true` lets tasks install packages and change system state where root is required.
  • The role order is deliberate: base system first, then the container runtime, then Kubernetes on top, then developer tools.
  • This is the one file you run to bring any Ubuntu 24.04 LTS box to the full baseline.

Entry point: `playbooks/workstation.yml`.

What The Roles Install

Base

`common`

Timezone, base packages, shell and MOTD setup, and consistent SSH configuration on every machine.

Containers

`docker`

Adds the official Docker apt repository, installs Docker CE with the Compose and Buildx plugins, and enables the service.

Kubernetes

`kubernetes`

Installs a single-node k3s cluster, drops a usable kubeconfig, and adds `kubectl` and `helm`.

Tooling

`dev_tools`

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.

Role Example: `docker` On Ubuntu 24.04

- 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
  • `suites: noble` targets Ubuntu 24.04 LTS specifically.
  • The role adds the official repository and key, then installs the full Docker package set in one step.
  • Adding the user to the `docker` group means everyday `docker` commands work without `sudo`.
  • Re-running is safe: apt and `user` are idempotent, so nothing is reinstalled needlessly.

Role Example: `kubernetes` Brings Up A Local Cluster

- 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
  • k3s is a lightweight, certified Kubernetes distribution — ideal for a local single-node cluster.
  • `creates:` makes the install step idempotent: if k3s is already there, the shell task is skipped.
  • Copying the generated kubeconfig into `~/.kube/config` lets `kubectl` talk to the cluster out of the box.
  • From here you can `kubectl apply` and `helm install` your workloads locally.

Hands-On: Set Up Your Own Machine

# 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
  • For your own box, `ansible_connection: local` runs everything on localhost — no SSH needed.
  • To set up a colleague's machine instead, point `ansible_host` at it and give an SSH user.
  • `--ask-become-pass` prompts for your sudo password so privileged tasks can run.
  • After the run: log out and back in once so your new `docker` group membership takes effect.

The Core Mental Model To Keep

Describe Inventory + vars
Apply Playbook + roles
Result Ready machine
What

What a machine should be

The inventory and variables express the target: Docker, a local Kubernetes cluster, and the standard toolchain.

How

How that state is achieved

The workstation playbook calls roles that install packages, configure the runtime, bring up the cluster, and set up tooling.

Repeatable

Same result every time

Any fresh Ubuntu 24.04 LTS box converges to the same baseline — and re-runs only fix what drifted.

Working rule

Change the code, not the machine

Need a new tool on every box? Add it to a role or variable file and re-run — don't install it by hand.

Questions?

From here, the best next step is to pick one real change and trace it from inventory to workflow to host.