Ansible Lesson 6 of 42

Ansible Core Modules for Real Work: package, service, copy, file, template, user & lineinfile

You have learned what a playbook is — plays, tasks, become, the execution model. But a play is only ever as useful as the modules the tasks call, and a handful of modules do the overwhelming majority of real work. If you can install a package, manage a service, lay down a file (verbatim or rendered from a template), make a surgical edit to a config that something else owns, create a user, fetch a tarball and unpack it, and pull a log back for inspection — you can already automate the configuration of almost any Linux server. This lesson is the exhaustive reference for exactly that toolkit: ansible.builtin.package/dnf/apt, ansible.builtin.service/systemd, ansible.builtin.copy/template/file, ansible.builtin.lineinfile/blockinfile, ansible.builtin.user/group, and ansible.builtin.get_url/unarchive/fetch. For each one you get the key options laid out in a table — what it is, the choices, the default, when to reach for it and the gotcha — plus the single most important property of all of them: idempotency, the rule that re-running a task already in the desired state changes nothing and reports ok, not changed.

I have written this to be the page you keep open while you write playbooks. It is beginner-accessible — every option and every term is explained the first time it appears — but it is deliberately complete, because the difference between a fragile script and a production-grade playbook is almost always a module option somebody did not know existed: the backup that saves you, the validate that refuses to install a broken config, the mode you got wrong because you typed 644 instead of '0644', the create: yes that turns lineinfile from a surgical tool into a file generator. We use fully-qualified collection names (FQCN) throughout — ansible.builtin.copy, not bare copy — because that is the modern, unambiguous, lint-clean way to write Ansible in 2026, and the habit will save you the day two modules in different collections share a short name. By the end you will reach for the right module and the right option by reflex, and the closing “configure a web server” mini-playbook shows the whole toolkit working together to turn a bare host into a running, content-serving, hardened web server — idempotently, so the hundredth run is as safe as the first.

Learning objectives

After working through this lesson you will be able to:

Prerequisites

You need a working control node and at least one managed node you can reach over SSH with privilege escalation, plus the playbook fundamentals: a play’s hosts, become, tasks, and the ansible-playbook command with --check and --diff. If you have read Ansible Playbooks, In Depth: Plays, Tasks, Modules, Become & Your First Playbook you have exactly the right footing — this lesson is the natural sequel, filling the otherwise-empty tasks: list with the modules that actually do the work. This is a Foundation (Playbooks) lesson in the Ansible Zero-to-Hero ladder: everything later — variables and facts, conditionals and loops, Jinja2 templating, roles — assumes you already know these modules cold, because they are what every role’s tasks/main.yml is built from. The lab at the end runs entirely against localhost and a free local container, so there is no cost to follow along.

Core concepts: modules, idempotency and the change report

Before the option tables, fix four ideas that apply to every module below.

A module is a small program that runs on the managed node and reports JSON. When a task calls ansible.builtin.copy, Ansible ships the module code (Python) plus your arguments to the target, runs it there against the target’s own Python interpreter, captures a JSON result, and removes the temporary code. Your control node never touches the target’s files directly — the module does, locally on the host. This is why modules are fast and why the target needs Python (for most modules), and why the result of every task is a structured object you can register and inspect (.changed, .rc, .stdout, module-specific keys).

Idempotency is the whole point — declare state, not steps. A well-written module is declarative: you describe the desired end state (“this package is present”, “this line exists in the file”, “this service is running and enabled”) and the module figures out whether reality already matches. If it does, the module does nothing and reports ok (unchanged); if it does not, the module makes the minimum change and reports changed. This is what lets you run the same playbook a hundred times safely. The two modules that are not idempotent — ansible.builtin.command and ansible.builtin.shell — run their command every time regardless, which is exactly why you prefer a purpose-built module whenever one exists, and why you use changed_when/creates/removes to teach the command modules when they really changed something.

changed versus ok is a status, and it drives handlers. Every task reports one of ok, changed, skipped, failed, or unreachable. The distinction between ok and changed is not cosmetic: a changed result is what fires a handler via notify (restart the service only if the config actually changed), and a clean run that reports all ok is your proof the system has converged on the desired state. If a task you expect to be idempotent reports changed on every run, that is a bug in how you described the state (a common cause: a command with no changed_when, or a lineinfile whose regexp does not match the line it just wrote).

Check mode and diff mode are your safety net. Run any playbook with --check (-C) and most of these modules will predict what they would change without touching anything (a “dry run”); add --diff and copy/template/lineinfile/blockinfile will show you the exact before/after of the file. Make this a reflex: --check --diff before a real run tells you precisely what is about to happen. (A few modules cannot fully support check mode — for example unarchive may report a change it cannot verify without extracting — but the core file/package/service modules support it well.)

Key terms used throughout: FQCN (fully-qualified collection name, namespace.collection.module, e.g. ansible.builtin.copy); idempotent (safe to re-run; converges on a declared state); become (privilege escalation, usually to root, required by most of these system-changing modules); handler (a task run only when notified by a changed result); mode (Unix permission bits, octal or symbolic); and fact (auto-discovered data about the host, e.g. ansible_facts['os_family'], used to pick the right package manager).

Managing software: package, dnf & apt

Installing and removing software is task number one on almost every host. Ansible gives you a generic module that auto-selects the right package manager and several specific modules (dnf, apt, yum, zypper, pacman, apk…) that expose options unique to one manager.

ansible.builtin.package is the portable choice: it inspects ansible_facts['pkg_mgr'] and dispatches to the right backend (dnf on RHEL/Fedora, apt on Debian/Ubuntu, and so on). Use it when your only requirement is “ensure this package is in this state” and the package name is the same across the distros you target. The moment you need a manager-specific feature — update_cache with a cache validity window on apt, enablerepo on dnf, autoremove, GPG-key handling — reach for the specific module.

Option (package / dnf / apt) What it does Choices / default Notes & gotchas
name Package(s) to act on A string or a list (['nginx','curl']); dnf/apt accept version pins Pin with nginx-1.24.* (dnf) or nginx=1.24.* (apt). A list installs all in one transaction — far faster than a loop.
state Desired state present (installed; default for package), latest (installed and upgraded to newest), absent (removed) latest makes the task non-idempotent in spirit — it may report changed whenever upstream publishes a newer version. Use present for reproducibility; latest only when you genuinely want rolling updates.
update_cache Refresh the package index first yes/no (default no) apt: equivalent to apt-get update; almost always needed before installing on Debian/Ubuntu or you get “package not found”. dnf: refreshes metadata.
cache_valid_time (apt only) Skip the cache refresh if it ran within N seconds integer seconds (default 0 = always refresh when update_cache: yes) Set e.g. 3600 to avoid hammering mirrors on every run while still keeping the cache reasonably fresh.
autoremove Remove now-unneeded dependencies yes/no (default no) apt: like apt autoremove. Pairs well with state: absent to clean orphaned deps.
autoclean (apt only) Clear the local downloaded-package archive yes/no (default no) Reclaims disk; cosmetic, not idempotent-relevant.
enablerepo / disablerepo (dnf/yum) Temporarily toggle repositories for this transaction repo id(s) e.g. install from EPEL without leaving it enabled: enablerepo: epel.
disable_gpg_check (dnf/yum) Skip package signature verification yes/no (default no) Security gotcha: leaving signature checks on is the default for a reason. Only disable for trusted local repos.
install_weak_deps (dnf) Pull in Recommends-style weak dependencies yes/no (default yes) Set no for lean/minimal images.
allow_downgrade (dnf) Permit installing an older version when pinned yes/no (default no) Needed when you pin a version lower than what is installed.
validate_certs (apt/dnf for remote repos) Verify TLS to repo mirrors yes/no (default yes) Keep yes in production.
deb (apt only) Install a local/remote .deb file directly path or URL Bypasses the repo for one-off packages; dependencies must already be satisfied.

Idempotency rule. state: present/absent are fully idempotent — the module checks the package database and only acts if the package’s installed-ness does not match. state: latest is idempotent against the current newest version: it reports ok if you already have the newest, changed if it upgraded. The realistic, portable pattern that respects facts looks like this:

- name: Ensure the web server and tools are present
  ansible.builtin.package:
    name:
      - nginx
      - curl
      - vim
    state: present
  become: true
# Debian/Ubuntu: refresh the cache (cheaply) then install
- name: Install on apt systems with a cached index
  ansible.builtin.apt:
    name: nginx
    state: present
    update_cache: true
    cache_valid_time: 3600
  become: true
  when: ansible_facts['os_family'] == "Debian"

Managing services: service & systemd

A package installed is not a service running. Two modules manage daemons: ansible.builtin.service (generic — works across SysV init, systemd, BSD rc, etc. by detecting the init system) and ansible.builtin.systemd (systemd-specific, which is what virtually every modern Linux uses; exposes daemon_reload, masked, and unit scope). Use service for portability; use systemd when you need a systemd-only feature.

The single most important distinction here is state versus enabled, and beginners conflate them constantly:

A service can be running now but disabled at boot, or enabled at boot but stopped now. Production tasks almost always want both state: started and enabled: true.

Option (service / systemd) What it does Choices / default Notes & gotchas
name The service/unit name string (e.g. nginx, or nginx.service) systemd accepts the .service suffix; either form works.
state The runtime state to enforce started, stopped, restarted, reloaded (no default — omit to leave runtime untouched) started/stopped are idempotent (act only if needed). restarted/reloaded are not — they fire every run. For “restart only when config changed”, do not use state: restarted in the task; use a handler notified by the config task.
enabled Start at boot? yes/no (no default — omit to leave the boot setting untouched) Idempotent. Independent of state.
daemon_reload (systemd) Run systemctl daemon-reload before acting yes/no (default no) Required after you add or change a unit file (e.g. you templated a new .service); without it systemd has not re-read the unit and your started may use the old definition.
daemon_reexec (systemd) Re-execute the systemd manager itself yes/no (default no) Rarely needed (after a systemd upgrade).
masked (systemd) Mask/unmask the unit yes/no A masked unit cannot be started by anything (symlinked to /dev/null). Stronger than disabled.
scope (systemd) system vs user manager system (default), user, global user/global manage per-user units (needs the right become_user/session).
no_block (systemd) Do not wait for the operation to finish yes/no (default no) For slow-starting units; you lose the success/fail signal.
force (service) Force through init-script quirks yes/no Backend-specific; rarely needed.

Idempotency rule. started/stopped/enabled converge on a state and report ok if already there. restarted/reloaded are actions, not states — they always run and always report changed. This is why the canonical pattern separates the config change (idempotent) from the restart (a handler, fired only on change):

- name: Ensure nginx is running now and on boot
  ansible.builtin.systemd:
    name: nginx
    state: started
    enabled: true
  become: true

# elsewhere: a config task that notifies, and a handler that restarts
- name: Deploy nginx config
  ansible.builtin.template:
    src: nginx.conf.j2
    dest: /etc/nginx/nginx.conf
    validate: nginx -t -c %s
  become: true
  notify: Restart nginx

# handlers:
#   - name: Restart nginx
#     ansible.builtin.systemd:
#       name: nginx
#       state: restarted
#     become: true

Delivering files: copy vs template vs file

This trio is the heart of configuration management, and choosing the right one is mostly about where the bytes come from:

The rule of thumb: static file → copy; dynamic file → template; just a path’s existence/attributes/permissions → file.

copy

Option (copy) What it does Choices / default Notes & gotchas
src Source file on the control node (or files/ in a role) path A trailing-slash directory copies its contents; no slash copies the directory itself (rsync-style). Mutually relevant with content.
content Inline literal content instead of src string Handy for tiny files; but no Jinja transformation beyond normal var-substitution in YAML — for real templating use template.
dest Destination path on the target (required) path If dest ends in /, it is treated as a directory and the basename of src is appended.
mode Permission bits octal string '0644' or symbolic u=rw,g=r,o=r Always quote octal ('0644'); see the modes section. preserve keeps the source’s mode.
owner / group Ownership username / group name Needs become to change to another owner.
backup Save a timestamped backup before overwriting yes/no (default no) The single best habit when touching existing files — you get dest.NNNN.YYYY-...~ to roll back to.
validate Run a command to check the file before putting it in place a command with %s placeholder for the temp file If the command exits non-zero, the task fails and does not install the file. e.g. validate: visudo -cf %s for sudoers. The killer safety feature.
force Overwrite when the dest differs yes (default) / no no = “only create if absent, never overwrite”.
remote_src src is on the target, not the control node yes/no (default no) Turns copy into a server-side copy (target → target).
directory_mode Mode for directories created during a recursive copy octal Only applies to newly created dirs.
decrypt Decrypt a Vault-encrypted src before copying yes (default) / no Lets you keep secrets vaulted in files/.

template

template takes all the file-attribute options of copy (dest, mode, owner, group, backup, validate, force) plus the rendering controls below. The source is a .j2 file (looked up in a role’s templates/ automatically).

Option (template-specific) What it does Choices / default Notes
src The Jinja2 template file path to .j2 Has access to all variables, facts, and magic vars in scope.
trim_blocks Remove the newline after a %} block tag yes (default in Ansible) / no Keeps rendered output tidy; the Ansible default differs from raw Jinja.
lstrip_blocks Strip leading whitespace before a block tag yes/no (default no) Lets you indent {% %} logic without it appearing in output.
block_start_string / variable_start_string etc. Change the Jinja delimiters strings For templating files that themselves contain {{ }} (e.g. another templating system).
newline_sequence Line ending of the output \n (default), \r\n, \r Use \r\n for files destined for Windows.
output_encoding Encoding of the rendered file default utf-8

A good template starts with the ansible_managed marker so humans know not to hand-edit it: # {{ ansible_managed }}.

file

Option (file) What it does Choices / default Notes & gotchas
path The path to manage (required) path Aliases: dest, name.
state What the path should be file (default — only sets attrs, errors if absent), directory (create, with recurse), link/hard (symlink/hardlink to src), touch (create empty / update mtime), absent (delete file/dir/link) state: directory is how you mkdir -p. state: absent is recursive for directories — careful. state: touch is not idempotent (always updates mtime → always changed).
src Target of a link path Required when state: link/hard.
mode / owner / group Permissions and ownership octal/symbolic; names Applied to the path. With recurse: yes on a directory, applied to all children.
recurse Apply ownership/permissions recursively yes/no (default no) Only valid with state: directory.
follow Follow symlinks when setting attributes yes (default) / no no operates on the link itself.
force Force link creation over an existing file / cross-filesystem yes/no (default no) Needed to replace a real file with a symlink.
access_time / modification_time Set timestamps preserve, now, or an explicit stamp Used with touch.
setype/seuser/serole SELinux context strings For SELinux relabelling without restorecon.

Idempotency rules. copy/template are idempotent by content checksum (and attributes): if the rendered/source bytes and the mode/owner already match the target, the task reports ok; otherwise it writes and reports changed (and with --diff shows you the change). file is idempotent for file/directory/link/absent — it only acts if the path’s state or attributes differ — except state: touch, which always updates the timestamp and therefore always reports changed.

Surgical edits: lineinfile & blockinfile

Sometimes you do not own a whole file — it is managed by a package, or shared, and you only need to ensure one setting is present (or absent). Dropping a full template over such a file is brittle. Two modules edit in place:

The single biggest lineinfile gotcha: regexp finds the line to act on; line is what it becomes. If your regexp does not match the line you ultimately write, the task will append a duplicate on the next run and report changed forever. Write the regexp to match the key (e.g. ^PermitRootLogin) and the line to be the full desired line.

Option (lineinfile) What it does Choices / default Notes & gotchas
path File to edit (required) path Alias dest.
line The exact line to ensure (for state: present) string What the matched line is replaced with, or appended if no match.
regexp Pattern to find the line to replace/remove a regex Matches the last occurrence by default. Anchor it (^Key) to target the directive, not its value.
state present or absent present (default), absent With absent, every line matching regexp (or equal to line) is removed.
insertafter Where to add the line if no match a regex, or EOF (default) e.g. insertafter: '^\[main\]' to add under a section.
insertbefore Add before a matching line / BOF a regex, or BOF Mutually exclusive with insertafter.
backrefs Allow \1 back-references from regexp in line, and do nothing if no match yes/no (default no) With backrefs: yes, a non-matching regexp leaves the file untouched (no append) — useful to edit-only-if-present.
create Create the file if it does not exist yes/no (default no) Turns lineinfile into a tiny file generator; usually you want no so a missing file is an error.
backup Timestamped backup before editing yes/no (default no) Recommended for shared files.
validate Validate the result before saving command with %s e.g. validate: 'sshd -t -f %s' — refuses to write a broken sshd_config.
mode/owner/group Set attributes (mainly with create) octal/symbolic; names
firstmatch Act on the first match rather than last yes/no (default no)
Option (blockinfile) What it does Choices / default Notes
path File to edit (required) path
block The multi-line content of the managed region string (often a ` ` block scalar)
marker The marker comment template default # {mark} ANSIBLE MANAGED BLOCK {mark} becomes BEGIN/END. Change the comment char for non-# files (marker: "// {mark} ...").
marker_begin / marker_end Custom BEGIN/END text strings Use distinct markers to manage multiple blocks in one file.
insertafter / insertbefore Where to place the block regex, EOF/BOF
state present or absent present (default), absent absent removes the whole marked region cleanly.
create / backup / validate / mode etc. As lineinfile

Idempotency rules. Both are idempotent provided your markers/regexp are stable: lineinfile re-checks whether a line matching the final state exists; blockinfile re-checks the content between its markers. The classic non-idempotent failure is a lineinfile whose regexp does not match its own line (duplicates accumulate), or a blockinfile whose block contains something that changes every run.

Accounts: user & group

ansible.builtin.group ensures a group exists (or not); ansible.builtin.user manages a user account end to end — shell, home, supplementary groups, password, and even SSH keys. Create groups before users that reference them.

Option (group) What it does Choices / default Notes
name Group name (required) string
state present/absent present (default), absent
gid Numeric group id integer Pin for consistency across hosts.
system Create as a system group yes/no (default no) Allocates a GID from the low system range.
Option (user) What it does Choices / default Notes & gotchas
name Username (required) string
state present/absent present (default), absent absent removes the account; add remove: yes to also delete the home dir (like userdel -r).
uid Numeric user id integer Pin for NFS/consistency.
group Primary group group name Must exist (create with group first).
groups Supplementary groups list or comma string See append.
append Add to groups rather than replace yes/no (default no) Critical gotcha: the default no means groups: sets the exact list, removing the user from any group not listed. Use append: yes to add without clobbering (e.g. add to sudo/wheel).
shell Login shell path (e.g. /bin/bash, /usr/sbin/nologin) nologin/false for service accounts.
home Home directory path path
create_home Create the home dir yes (default) / no
password The already-hashed password for /etc/shadow a crypt hash Never a plaintext password. Generate with mkpasswd --method=sha-512 or the password_hash('sha512') Jinja filter; store vaulted. A plaintext value here just sets a literally-wrong hash.
password_lock Lock/unlock the password yes/no Locks login via password without deleting the account.
update_password When to (re)set the password always (default) / on_create on_create avoids resetting an existing user’s password on every run.
system Create as a system account yes/no (default no) Low UID, no aging — for daemons.
generate_ssh_key Generate an SSH keypair for the user yes/no (default no) With ssh_key_bits, ssh_key_type, ssh_key_file.
ssh_key_* Key parameters type/bits/file/comment/passphrase Generated under the user’s ~/.ssh.
expires Account expiry as a Unix timestamp float (epoch seconds), -1 to clear e.g. temporary contractor accounts.
comment GECOS field (full name) string
remove With state: absent, also delete home/mail spool yes/no (default no) The -r of userdel.

To install a user’s authorized public key, prefer the dedicated ansible.posix.authorized_key module (FQCN, from the ansible.posix collection) over hand-editing ~/.ssh/authorized_keys — it is idempotent and supports exclusive, key_options, and removal.

Idempotency rules. user/group converge on the declared account state and report ok if nothing differs. The two re-run traps are: update_password: always with a password value (it may report changed each run depending on hashing — prefer on_create), and forgetting append: yes (so each run “corrects” the supplementary groups back to the listed set).

Moving files around: get_url, unarchive & fetch

The last three close the loop on getting bytes onto — and off — a host:

Option (get_url) What it does Choices / default Notes & gotchas
url Source URL (required) http(s)/ftp/file
dest Where to save on the target (required) path or dir If dest is a directory, the filename is taken from the URL/headers.
checksum Verify the download algorithm:value or algorithm:url e.g. sha256:abc... or sha256:https://.../SHA256SUMS. Use this — it makes the download verifiable and idempotent (skips re-download if the file matches).
mode/owner/group Attributes on the saved file octal/symbolic; names
force Re-download even if dest exists yes/no (default no) With no and no checksum, an existing dest is left as-is.
headers Extra HTTP headers dict For auth tokens, custom UA.
url_username / url_password HTTP basic auth strings (vault the password)
validate_certs Verify TLS yes (default) / no Keep yes; no is for internal self-signed only.
timeout Request timeout (seconds) default 10
tmp_dest Temp dir for the download path Useful when /tmp is small or noexec.
Option (unarchive) What it does Choices / default Notes & gotchas
src The archive path (control node) or URL With remote_src: yes, src is a path/URL on the target.
dest Directory to extract into (required) path Must exist (create with file: state=directory first).
remote_src src is already on the target (or a URL to fetch on the target) yes/no (default no) no = copy from control node then extract; yes + URL = download-and-extract on the target.
creates Skip if this path already exists path The idempotency lever: set it to a file the archive produces so re-runs are no-ops.
extra_opts Extra args to the archive tool list (e.g. ['--strip-components=1']) Very commonly needed to flatten a top-level dir.
owner/group/mode Attributes on extracted files names; octal
keep_newer Do not overwrite files newer on disk yes/no (default no)
list_files Return the list of files in the archive yes/no (default no) Populates .files in the result.
include/exclude Extract only / skip listed members lists

Idempotency note for unarchive: by itself it tends to report changed because it cannot cheaply diff every extracted file (and check mode is limited). Give it a creates: path (something the archive yields) so subsequent runs skip extraction and report ok. get_url with a checksum is genuinely idempotent — it skips the download when the target already matches.

Option (fetch) What it does Choices / default Notes & gotchas
src File on the managed node to pull (required) path Must be a single file, not a directory.
dest Directory on the control node to store it (required) path By default Ansible nests the file under dest/<hostname>/<src-path> so files from many hosts do not collide.
flat Store directly at dest without the host/path nesting yes/no (default no) With flat: yes, dest ending in / keeps the basename; otherwise dest is the exact filename.
fail_on_missing Fail if src does not exist on the target yes (default in modern versions) / no
validate_checksum Verify the file arrived intact yes (default) / no

Idempotency rule. fetch is idempotent by checksum — it only re-downloads if the control-node copy differs from the target’s file.

File modes: octal vs symbolic (and the YAML gotcha)

Every file/copy/template/get_url task can set mode, and getting it right matters for security. Linux permissions are three triads — owner, group, other — each with read (r=4), write (w=2), execute (x=1).

Octal Symbolic equivalent Meaning
'0644' u=rw,g=r,o=r Owner read/write; everyone else read. The default for most files.
'0600' u=rw,g=,o= Owner read/write only. Secrets (keys, vaulted material on disk).
'0755' u=rwx,g=rx,o=rx Executable/dir everyone can traverse; only owner writes. Scripts, directories.
'0750' u=rwx,g=rx,o= Owner full, group read/traverse, others nothing. Group-shared dirs.
'0700' u=rwx,g=,o= Owner only. ~/.ssh style.
'1777' symbolic + sticky World-writable with sticky bit (/tmp). The leading 1 is the sticky bit; 2=setgid, 4=setuid.

The single most common Ansible-beginner bug: writing mode: 0644 unquoted. YAML interprets a leading-zero integer differently from the octal you intended, and Ansible can apply the wrong permissions silently. Always quote octal modesmode: '0644' — or use the unambiguous symbolic form mode: u=rw,g=r,o=r. Symbolic also supports relative changes (mode: u+x to add execute for the owner without restating the rest), which is handy for file tasks that adjust one bit.

Diagram

Ansible core modules: how package, service, file-delivery, edit, account and transfer modules act on a managed node

The diagram groups the core modules by job — install (package/dnf/apt), run (service/systemd), deliver (copy/template/file), edit in place (lineinfile/blockinfile), accounts (user/group) and transfer (get_url/unarchive/fetch) — and shows each acting on the managed node while reporting ok/changed back to the control node, the loop that makes idempotent convergence visible.

Hands-on lab

We will build a real, running web server with this exact toolkit — package install, a directory and a templated config, content, a service, a hardened sshd edit, an account, a downloaded file and a fetched log — entirely free. You have two ways to run it; pick one:

0. Prerequisites check. On the control node confirm Ansible is present:

ansible --version        # ansible-core 2.17+ expected

1. (Option A) Start a systemd-capable container as the target.

# Podman or Docker; this image runs systemd as PID 1
docker run -d --name web1 --hostname web1 \
  --tmpfs /tmp --tmpfs /run -v /sys/fs/cgroup:/sys/fs/cgroup:ro \
  registry.access.redhat.com/ubi9/ubi-init:latest /sbin/init

Create an inventory inventory.ini that reaches the container through the Docker connection plugin (no SSH needed):

[web]
web1 ansible_connection=docker

(For Option B instead, use web1 ansible_connection=local ansible_host=localhost and run on a disposable VM.)

2. Create the project files. Make a folder ansible-webserver/ containing the inventory above, the template, the homepage, and the playbook.

nginx_index.html.j2 (a Jinja2 template so it shows facts):

{# This is rendered by ansible.builtin.template #}
<!doctype html>
<html>
  <head><title>Served by {{ inventory_hostname }}</title></head>
  <body>
    <h1>Hello from {{ inventory_hostname }}</h1>
    <p>OS family: {{ ansible_facts['os_family'] }} — managed by Ansible.</p>
  </body>
</html>

site.yml — the mini web-server playbook that exercises the whole toolkit:

---
- name: Configure a web server end to end
  hosts: web
  become: true

  vars:
    web_root: /usr/share/nginx/html
    app_user: webadmin

  tasks:
    # --- install (package) ---
    - name: Ensure nginx and tools are installed
      ansible.builtin.package:
        name:
          - nginx
          - curl
        state: present

    # --- account (group + user) ---
    - name: Ensure the web group exists
      ansible.builtin.group:
        name: web
        state: present

    - name: Ensure a non-login web admin user exists
      ansible.builtin.user:
        name: "{{ app_user }}"
        groups: web
        append: true
        shell: /usr/sbin/nologin
        create_home: true
        state: present

    # --- deliver (file: a dir; template: rendered config & homepage) ---
    - name: Ensure the document root exists
      ansible.builtin.file:
        path: "{{ web_root }}"
        state: directory
        owner: "{{ app_user }}"
        group: web
        mode: '0755'

    - name: Render the homepage from a template
      ansible.builtin.template:
        src: nginx_index.html.j2
        dest: "{{ web_root }}/index.html"
        owner: "{{ app_user }}"
        group: web
        mode: '0644'
        backup: true

    # --- deliver (copy: inline static content) ---
    - name: Drop a static health-check file (verbatim)
      ansible.builtin.copy:
        content: "ok\n"
        dest: "{{ web_root }}/health.txt"
        mode: '0644'

    # --- download (get_url) ---
    - name: Download a static asset with checksum verification
      ansible.builtin.get_url:
        url: https://raw.githubusercontent.com/torvalds/linux/master/README
        dest: "{{ web_root }}/README.txt"
        mode: '0644'
      # In a locked-down lab without egress, comment this task out.

    # --- surgical edit (lineinfile) on a file we do not own ---
    - name: Harden sshd  disable root login (validated)
      ansible.builtin.lineinfile:
        path: /etc/ssh/sshd_config
        regexp: '^#?PermitRootLogin'
        line: 'PermitRootLogin no'
        validate: 'sshd -t -f %s'
        backup: true
      notify: Restart sshd

    # --- run + enable (service) ---
    - name: Ensure nginx is started and enabled
      ansible.builtin.service:
        name: nginx
        state: started
        enabled: true

  handlers:
    - name: Restart sshd
      ansible.builtin.service:
        name: sshd
        state: restarted

3. Dry-run first (the safety reflex).

ansible-playbook -i inventory.ini site.yml --check --diff

Expected: every task shows what it would do; --diff prints the rendered index.html, the health.txt content, and the one-line change to sshd_config. Nothing is applied.

4. Apply for real.

ansible-playbook -i inventory.ini site.yml

Expected recap: a row of changed= for the first run. Read it — package install changed, the user/group changed, the directory and both files changed, the sshd line changed (and the handler Restart sshd runs once, at the end, because the line changed).

5. Validate it actually serves.

# From inside the container (Option A):
docker exec web1 curl -s localhost/health.txt          # -> ok
docker exec web1 curl -s localhost/ | grep "Hello from" # -> the templated homepage
docker exec web1 grep '^PermitRootLogin' /etc/ssh/sshd_config  # -> PermitRootLogin no
docker exec web1 id webadmin                            # -> shows the 'web' group
docker exec web1 systemctl is-enabled nginx             # -> enabled

6. Prove idempotency — run it again.

ansible-playbook -i inventory.ini site.yml

Expected: the recap now shows changed=0 (or close to it; get_url with a checksum and the rest all converge). The handler does not run, because no config changed. This ok-everywhere second run is the proof your playbook is idempotent — the single most important property to demonstrate in an interview.

7. Pull a file back (fetch).

# Append a fetch play, or run ad-hoc:
ansible -i inventory.ini web -b -m ansible.builtin.fetch \
  -a "src=/var/log/messages dest=./pulled/ flat=no"
ls -R pulled/   # -> pulled/web1/var/log/messages

8. Cleanup.

docker rm -f web1            # Option A: removes the whole target — nothing left behind
rm -rf ./pulled              # remove fetched files

For Option B (you targeted a real VM), reverse the changes by setting state: absent/stopped or simply discard the throwaway VM. Cost note: the entire lab runs on your machine — a local container or VM and Ansible itself — so the cost is ₹0. No cloud resources are created.

Common mistakes & troubleshooting

Symptom Likely cause Fix
A lineinfile task appends a duplicate line every run (always changed) The regexp does not match the line you write, so Ansible never “finds” its own line Make regexp match the key (^PermitRootLogin) and line the full desired line; they must reconcile
mode applied wrong permissions silently You wrote mode: 0644 unquoted — YAML mis-parses the octal Always quote: mode: '0644', or use symbolic u=rw,g=r,o=r
Service “started” but the new unit file is ignored You templated a .service but did not reload systemd Add daemon_reload: true to the systemd task (or notify a reload handler)
apt: “package not found” / stale versions Cache not refreshed Add update_cache: true (with cache_valid_time to avoid over-refreshing) on Debian/Ubuntu
user keeps removing the user from other groups append defaults to no, so groups: sets the exact list Add append: true to add supplementary groups without clobbering
user’s password set to a literally-wrong value / login fails You put a plaintext password in password: Pass a hash (password_hash('sha512') filter or mkpasswd), vaulted; consider update_password: on_create
unarchive reports changed on every run It cannot cheaply detect already-extracted content Add creates: <a path the archive produces> so re-runs are no-ops
service: restarted restarts on every run even when nothing changed restarted is an action, not a state Move the restart into a handler and notify it only from the config task
copy/template overwrote a file and you needed the old one No backup taken Add backup: true; the timestamped backup lets you roll back
A bad config got installed and broke the service No validation Add validate: '<checker> %s' (e.g. nginx -t -c %s, sshd -t -f %s, visudo -cf %s) so a broken file is refused before install
get_url keeps re-downloading No checksum and force defaults make it re-fetch in some flows Provide checksum: sha256:... so an up-to-date file is skipped

Best practices

Security notes

These modules are your security posture on a host, so a few rules carry weight. File modes and ownership are the front line: lay secrets down as '0600' owned by the right user, document roots as '0644'/'0755', and never world-write a config — a stray '0666' from an unquoted mode is a real vulnerability. Never put a plaintext password in user: password: — it expects a hash; pass "{{ 'S3cret' | password_hash('sha512') }}" and keep the source vaulted, and prefer update_password: on_create so you are not rewriting /etc/shadow on every run. Keep TLS and signature verification on: get_url’s validate_certs: true and dnf/apt’s GPG checks default to secure for a reason — disable them only for trusted internal sources, never as a blanket workaround. validate: is a security control, not just a convenience: validating sshd_config (sshd -t -f %s) or sudoers (visudo -cf %s) before installation prevents a templating mistake from locking you out or escalating privilege. When you download-and-extract with unarchive from a URL, verify the source (pin the URL, supply a checksum to get_url and extract the verified file) rather than piping arbitrary internet bytes into tar. Finally, become only where needed — these tasks need root, but scope become: true to the play or the specific tasks that require it rather than leaving it on globally where a templating bug could write somewhere sensitive.

Interview & exam questions

1. What is idempotency, and which two core modules are not idempotent by default? Idempotency means re-running a task already in the desired state changes nothing and reports ok. Modules declare a desired state and only act if reality differs. ansible.builtin.command and ansible.builtin.shell are not idempotent — they run their command every time — which is why you prefer purpose-built modules or add creates/removes/changed_when.

2. In the service/systemd module, what is the difference between state and enabled? state controls the service right now (started/stopped/restarted/reloaded); enabled controls whether it starts on boot (yes/no). They are independent — a service can be running now but disabled at boot, or vice versa. Production usually wants both state: started and enabled: true.

3. When do you need daemon_reload: true? After you add or change a systemd unit file (e.g. you templated a new .service). Without daemon-reload, systemd has not re-read the unit, so a started/restarted may act on the old definition.

4. copy vs template vs file — when each? copy puts fixed bytes (a static src or inline content) on the target with no transformation. template renders a Jinja2 .j2 using variables/facts, then writes the result — use it whenever content depends on anything. file delivers no content; it enforces a path’s state (directory/link/touch/absent) and attributes (mode/owner/group).

5. Why might a lineinfile task be changed on every run, and how do you fix it? Because its regexp does not match the line it writes, so on the next run Ansible doesn’t recognise its own line and appends a duplicate. Fix: anchor regexp to the directive key (^PermitRootLogin) so it matches the line line produces.

6. How do you restart a service only when its config changed? Put the restart in a handler and notify: it from the config task. The config task is idempotent (reports changed only on a real change), and a handler runs once at the end of the play, only if notified. Using state: restarted directly in a task restarts on every run.

7. What is the mode: 0644 (unquoted) gotcha? YAML parses a leading-zero integer in a way that does not match the octal you intended, so Ansible can apply the wrong permissions silently. Always quote octal ('0644') or use symbolic (u=rw,g=r,o=r).

8. In the user module, what does append default to and why does it matter? append defaults to no, which means groups: sets the user’s exact supplementary group list — removing them from any group not listed. Use append: true to add a user to a group (e.g. wheel/sudo) without clobbering their other memberships.

9. How should a password be supplied to the user module? As an already-computed hash (e.g. "{{ 'pw' | password_hash('sha512') }}" or mkpasswd --method=sha-512), stored vaulted — never plaintext, which would set a literally-wrong hash. Prefer update_password: on_create to avoid rewriting /etc/shadow each run.

10. How do you make unarchive idempotent, and how is that different from get_url? Give unarchive a creates: path (a file the archive produces) so re-runs skip extraction. get_url is idempotent natively when you supply a checksum: — it skips the download if the target file already matches.

11. What does fetch do, and how does it avoid filename collisions across hosts? fetch copies a file from the managed node back to the control node (the reverse of copy). By default it nests the file under dest/<hostname>/<src-path>, so the same file pulled from many hosts does not overwrite. flat: yes disables the nesting.

12. What is the purpose of the validate: option, and give two real uses? validate: runs a command (with %s as the temp file) to check the file before it is installed; a non-zero exit fails the task and nothing is written. Real uses: validate: 'sshd -t -f %s' for sshd_config and validate: 'visudo -cf %s' for sudoers — preventing a broken config from being deployed.

13. Why prefer package sometimes and dnf/apt other times? package is portable — it auto-selects the host’s package manager via facts — so use it when the package name is the same across distros and you need no manager-specific feature. Use dnf/apt when you need features unique to one manager (update_cache/cache_valid_time, enablerepo, autoremove, deb).

Quick check

  1. Which option makes unarchive idempotent by skipping extraction when a path already exists?
  2. True or false: mode: 0755 and mode: '0755' are equivalent in a YAML playbook.
  3. You need a config change to trigger a service restart only when it actually changes. What mechanism do you use?
  4. Which module copies a file from the managed node to the control node?
  5. In the user module, what must you add so that groups: adds memberships instead of replacing them?

Answers

  1. creates: — set it to a path the archive produces; subsequent runs report ok instead of changed.
  2. False. Unquoted 0755 can be mis-parsed by YAML and apply the wrong bits; always quote octal modes (or use symbolic form).
  3. A handler plus notify: — the idempotent config task notifies a handler that does the restart, which runs once at the end of the play only if something changed. (Do not use state: restarted directly in the task.)
  4. ansible.builtin.fetch — the reverse of copy; it pulls a file back to the control node (nesting it under the hostname by default).
  5. append: true — without it, groups: sets the exact supplementary-group list and removes the user from any group not listed.

Exercise

Extend the lab’s site.yml into a more production-shaped web role-in-a-playbook:

  1. Replace the inline copy health file with a templated health.json (ansible.builtin.template) that includes {{ ansible_facts['hostname'] }} and a {{ ansible_managed }} header comment, with mode: '0644'.
  2. Add a blockinfile task that manages a custom # BEGIN/END ANSIBLE MANAGED BLOCK region in a file under /etc/, with validate: where the file type supports it, and prove with --diff that it is added on the first run and unchanged on the second.
  3. Create the web admin user’s SSH key with the user module’s generate_ssh_key: true (and ssh_key_type: ed25519), then use ansible.builtin.fetch to pull the generated public key (~webadmin/.ssh/id_ed25519.pub) back to your control node.
  4. Download a release tarball with get_url with a checksum:, then unarchive it (remote_src: true, a creates: path, and extra_opts: ['--strip-components=1']) into a directory you first ensure exists with file: state=directory.
  5. Convert the nginx restart to a proper handler notified by the template task, and demonstrate that editing the template fires exactly one restart while a no-op run fires none.

Write two or three sentences explaining which tasks reported changed on your second run and why each one did or did not — the ability to reason about ok vs changed per module is the core skill this lesson builds.

Certification mapping

This lesson maps to the Red Hat Certified Engineer (RHCE), exam EX294 — “Use Ansible modules for system administration tasks” and “Create and use templates to create customised configuration files.” Specifically it covers: software management with package/dnf/apt (state present/latest/absent, update_cache); service management with service/systemd (state vs enabled, daemon_reload); delivering files with copy (src/content, validate, backup), template (Jinja2 rendering, trim_blocks/lstrip_blocks, ansible_managed) and file (directory/link/touch/absent, recurse); editing with lineinfile and blockinfile (regexp, insertafter, validate); user and group administration with user (groups/append, shell, hashed password, SSH keys, system accounts) and group; and file transfer with get_url (checksum), unarchive (creates, extra_opts) and fetch. The EX294 exam is hands-on and explicitly tests idempotency, correct mode handling, and using validate/backup on edited config files — all drilled above. The same modules underpin Ansible work in the broader DevOps and Red Hat administration tracks.

Glossary

Next steps

You now command the day-one module toolkit — installing software, running services, delivering and editing files, managing accounts, and moving bytes on and off a host, all idempotently. Every option here becomes far more powerful once it can react to data, so the natural next move is the data layer. Continue with Ansible Variables & Facts, In Depth: the 22-Level Precedence, Facts, register & set_fact, which explains where variables come from and in what order they win, how to capture a task’s result with register, and how facts feed the when conditions and templates that make these modules adapt per host. And to go deeper on the rendering engine behind the template module — filters, tests, loops and lookups — see Ansible Jinja2 Templating, In Depth: the template Module, Filters, Tests & Lookups.

AnsibleModulesIdempotencypackageservicetemplate
Need this built for real?

Vinod is a Senior Cloud Architect (22+ yrs) — available for Azure / AWS / GCP architecture, landing zones, and migrations.

Work with me

Comments