Skip to content

Ansible — the 80/20 course for static AWS EC2

Scope: configure and operate a small fleet of already-running EC2 instances from your laptop or a small build server. Provisioning instances, dynamic inventories, and Tower/AWX are intentionally out of scope — pointers at the end if you ever need them.

Read this top-to-bottom once, then come back to specific sections as reference. This module's glossary is the dictionary for any term you don't recognise here — it also seeds the Retain flashcards.


TL;DR — in 30 seconds:

  • Ansible is agentless and push-based: no daemon on the target — it SSHes in, runs a one-shot Python payload, then disconnects; you trigger every run from your control node.
  • Idempotency is the point: a built-in module reports changed only when it actually altered the host, so a second run of the same playbook should come back all ok — zero changes.
  • Scope here is static, already-running EC2 instances — inventory, playbooks, roles, and vaulted secrets — not provisioning instances or dynamic inventory (Terraform's job, see "How this connects").

1. Why Ansible — and when not to use it

Ansible is a declarative configuration tool: you describe the end state of a machine (package X installed, service Y running, file Z with this content) and Ansible converges the machine to that state. If the state already matches, Ansible does nothing — that's idempotency.

What makes it different from Chef / Puppet / Salt:

  • Agentless: no daemon on the target. Ansible logs in over SSH, runs a one-shot Python program in /tmp, removes it, and disconnects. Your EC2 boxes don't need to register anywhere.
  • Push model: you trigger runs from a control node (your laptop, a CI runner, a tiny dedicated EC2). Targets are passive.
  • Plain YAML: no DSL to learn, no Ruby, no compiled state files.
  • Fail-fast, line-by-line: each task either succeeds, is skipped, fails, or changes the host. Failures stop the play on that host immediately.

When not to reach for Ansible:

  • You need fully-immutable infrastructure → bake AMIs with Packer, replace instances, skip configuration management.
  • You need real-time orchestration of events across many machines → use a message bus, not Ansible.
  • You need state-tracking of cloud resources (VPCs, IAM, RDS) → use Terraform / CloudFormation; Ansible can do it, but its strength is config-on-existing-hosts.

The 80/20 sweet spot Ansible nails: post-boot configuration of static hosts you already own.


2. Mental model

flowchart LR
    subgraph CN["Control node (your laptop)"]
        CLI["Ansible CLI"]
        INV["Inventory file"]
        PB["Playbooks (YAML)"]
    end
    subgraph MN["Managed node (EC2 instance)"]
        SSHD["sshd"]
        PY["Python (system)"]
    end
    CN -- "SSH (port 22): copy small Python payload to /tmp, execute, remove" --> MN
    MN -- "JSON result" --> CN

Three things to internalise:

  1. The control node has all the brains. It reads your inventory, renders your templates, decides which tasks run on which hosts. The target just executes the Python payload it's handed.
  2. Targets need Python. Almost every module is a Python script. Modern Amazon Linux 2023, Ubuntu 22.04+, Debian 12+ ship a usable Python — you don't install anything Ansible-specific on the target.
  3. Everything runs over SSH. If you can ssh ec2-user@10.0.1.42 you can ansible it. If you can't, no Ansible voodoo will save you — fix SSH first.

3. Setup on the control node

Use a Python virtualenv. The Ansible team ships Ansible as a single Python package (ansible-core) plus optional collections.

python3 -m venv .venv
source .venv/bin/activate
pip install 'ansible-core>=2.16'
ansible --version

Your repo layout will look like this — keep it small, no Ansible Tower / AWX nonsense:

infra/
├── ansible.cfg              # repo-local config (so CLI picks it up automatically)
├── inventory/
│   ├── hosts.yml            # static inventory (the EC2 boxes)
│   ├── group_vars/
│   │   └── all.yml          # vars for every host
│   └── host_vars/
│       └── web-01.yml       # per-host overrides
├── playbooks/
│   ├── site.yml             # top-level entry point
│   └── nginx.yml            # focused playbook
├── roles/
│   └── nginx/               # `ansible-galaxy init nginx` scaffold
└── group_vars/all/
    └── vault.yml            # ansible-vault-encrypted secrets

A minimal ansible.cfg for this layout:

[defaults]
inventory          = inventory/hosts.yml
host_key_checking  = False        ; OK for ephemeral lab; flip to True with known_hosts in CI
retry_files_enabled = False
forks              = 20           ; parallelism cap; 20 is fine for small fleets
stdout_callback    = yaml         ; readable output
roles_path         = roles
collections_paths  = ./collections

[ssh_connection]
pipelining = True                 ; ~30% speedup; safe when sudo is configured normally
ssh_args   = -o ControlMaster=auto -o ControlPersist=60s

pipelining = True is the single biggest free perf win. Turn it on.


4. Static inventory for EC2

You have two file formats. Use YAML. INI is older and shorter, YAML is uniformly readable and supports nested groups + structured vars.

inventory/hosts.yml:

all:
  vars:
    ansible_user: ec2-user                        # Amazon Linux 2023 default
    ansible_ssh_private_key_file: ~/.ssh/hiro.pem
    ansible_python_interpreter: /usr/bin/python3
  children:
    web:
      hosts:
        web-01:
          ansible_host: 52.59.10.11               # public IPv4 or DNS
        web-02:
          ansible_host: 52.59.10.12
    db:
      hosts:
        db-01:
          ansible_host: 52.59.10.20
          ansible_user: ubuntu                    # different AMI = different default user
          ansible_ssh_private_key_file: ~/.ssh/hiro-db.pem
    prod:
      children:
        web: {}
        db: {}

Things that bite:

  • ansible_user matters. Amazon Linux 2023 → ec2-user. Ubuntu → ubuntu. Debian → admin. Rocky/RHEL → ec2-user or rocky. Different AMI ⇒ override at host level.
  • ansible_host ≠ host name. The key (web-01) is the inventory name you use everywhere else; ansible_host is the IP/DNS Ansible actually SSHes to. Keep them distinct so renaming a public IP doesn't ripple into every playbook.
  • Bastion / jumphost. If your EC2 is in a private subnet, you don't expose port 22. Use ansible_ssh_common_args:
all:
  vars:
    ansible_ssh_common_args: '-o ProxyCommand="ssh -W %h:%p -q ec2-user@bastion.example.com"'

Set up ~/.ssh/config instead if you can — Ansible inherits it, and it works for plain ssh too.


5. Ad-hoc commands — your first real run

Ad-hoc commands let you skip the playbook ceremony and run a single module against an inventory pattern. They are the fastest way to prove SSH + Python work before you write any YAML.

ansible all -m ping                          # SSH + Python check on every host
ansible web -m setup -a 'filter=ansible_distribution*'
ansible prod -m shell -a 'uptime'            # use 'shell' only when no module fits
ansible web-01 -m service -a 'name=nginx state=restarted' --become

-m is the module, -a is its arguments. --become runs the task as root (equivalent to sudo on the target).

If ansible all -m ping returns pong on every host, you have the platform working. Anything beyond that is just writing playbooks.


6. Your first playbook

A playbook is a YAML file with one or more plays. A play binds a list of tasks (each task = one module call) to a set of hosts.

playbooks/nginx.yml:

- name: Configure nginx web tier
  hosts: web
  become: true                                    # run as root for the whole play
  gather_facts: true                              # collect ansible_distribution etc.

  tasks:
    - name: Install nginx
      ansible.builtin.package:
        name: nginx
        state: present

    - name: Drop a custom index page
      ansible.builtin.copy:
        dest: /usr/share/nginx/html/index.html
        content: "Hello from {{ inventory_hostname }} ({{ ansible_distribution }})\n"
        owner: root
        group: root
        mode: '0644'

    - name: Ensure nginx is running and enabled
      ansible.builtin.service:
        name: nginx
        state: started
        enabled: true

Run it:

ansible-playbook playbooks/nginx.yml
ansible-playbook playbooks/nginx.yml --check --diff      # dry-run, show would-be changes
ansible-playbook playbooks/nginx.yml --limit web-01      # one host

Read the output carefully the first few times:

  • ok — module ran, host already in desired state, nothing changed.
  • changed — module made a change.
  • skippedwhen: condition false.
  • failed — task failed; play halts on that host.

The change/ok distinction is the visible face of idempotency. A second run of the same playbook should produce all ok and zero changed. If it doesn't, your tasks aren't idempotent — almost always because you used command: / shell: where a real module exists.


7. Modules you'll use 80% of the time

You don't need to learn the 4000+ modules. The list below covers most static-EC2 work:

Module Use for
ansible.builtin.package OS-package install/remove (delegates to apt/dnf/yum).
ansible.builtin.service Start/stop/enable services (delegates to systemd).
ansible.builtin.systemd_service Same but exposes systemd-specific options.
ansible.builtin.copy Push a file (small) or literal content.
ansible.builtin.template Render a Jinja2 template and push the result.
ansible.builtin.file Manage path state: directory, symlink, perms, ownership.
ansible.builtin.lineinfile Surgical edit: ensure one line is in a file.
ansible.builtin.blockinfile Same for a multi-line block (delimited by markers).
ansible.builtin.user Create/remove a Linux user.
ansible.posix.authorized_key Add/remove SSH keys from ~/.ssh/authorized_keys.
ansible.builtin.command Run an arbitrary binary (no shell). Use last resort.
ansible.builtin.shell Run through /bin/sh. Even more last-resort.
ansible.builtin.cron Manage crontab entries.
ansible.builtin.get_url Download a file from an URL.
ansible.builtin.unarchive Extract a .tar.gz / .zip (optionally fetch first).
ansible.builtin.uri Hit an HTTP endpoint (smoke-test, register a webhook).
ansible.builtin.debug Print a variable or message during a run.

Rule of thumb: if command: / shell: shows up in your playbook, ask "does a module exist for this?". 9 times out of 10 it does, and using it gets you idempotency for free.


8. Variables and facts

There are two flavours of data in Ansible:

  1. Variables — anything you set. Live in: inventory (group_vars, host_vars), playbook vars:, role defaults: / vars:, command-line -e, set_fact:.
  2. Facts — gathered from the target at the start of the play (when gather_facts: true). Things like ansible_distribution, ansible_default_ipv4.address, ansible_memtotal_mb. Use them to branch on the OS, the architecture, the available memory.

Precedence (simplified — the full table has 22 rows but you'll never need 19 of them):

command-line  -e var=value     ← wins
playbook vars:
role vars/
host_vars/
group_vars/
role defaults/                 ← loses

A small playbook with vars + facts:

- name: Demo
  hosts: web
  vars:
    site_name: hiro
  tasks:
    - name: Print distro and a custom var
      ansible.builtin.debug:
        msg: "Host {{ inventory_hostname }} runs {{ ansible_distribution }} {{ ansible_distribution_version }}, site={{ site_name }}"

    - name: Compute and remember a value
      ansible.builtin.set_fact:
        upload_dir: "/var/lib/{{ site_name }}/uploads"

    - name: Use it
      ansible.builtin.file:
        path: "{{ upload_dir }}"
        state: directory
        mode: '0755'

register: captures a task's result for later use — second pillar after facts:

- name: Is nginx running?
  ansible.builtin.systemd_service:
    name: nginx
  register: nginx_status

- name: Restart only if not active
  ansible.builtin.service:
    name: nginx
    state: restarted
  when: nginx_status.status.ActiveState != "active"

9. Conditionals and loops

when: is a Jinja2 expression evaluated on the control node:

- name: Install firewalld on RHEL-likes only
  ansible.builtin.package:
    name: firewalld
    state: present
  when: ansible_os_family == "RedHat"

loop: (modern; older syntax was with_items:):

- name: Ensure several users exist
  ansible.builtin.user:
    name: "{{ item.name }}"
    shell: "{{ item.shell | default('/bin/bash') }}"
    groups: "{{ item.groups | default(omit) }}"
  loop:
    - { name: alice }
    - { name: bob, groups: 'sudo,docker' }

omit is the Ansible-specific sentinel meaning "pretend I didn't pass this argument". Pair it with default(omit) for optional arguments.


10. Templating with Jinja2

template: renders a Jinja2 file on the control node, then copy:-s the result to the target. Every variable, fact, register output is accessible.

roles/nginx/templates/site.conf.j2:

server {
    listen 80 default_server;
    server_name {{ site_server_name }};
    root /var/www/{{ site_name }};

    location / {
        try_files $uri $uri/ =404;
    }

    {% if site_extra_headers is defined %}
    {% for h, v in site_extra_headers.items() %}
    add_header {{ h }} "{{ v }}";
    {% endfor %}
    {% endif %}
}

Task:

- name: Render nginx site config
  ansible.builtin.template:
    src: site.conf.j2
    dest: /etc/nginx/conf.d/site.conf
    owner: root
    group: root
    mode: '0644'
    validate: 'nginx -t -c %s'         # syntax-check before swapping it in
  notify: Reload nginx

Jinja2 filters you'll actually use: default, to_yaml, to_nice_yaml, to_json, mandatory, b64encode, b64decode, length, join(","), regex_replace. The full catalog is overkill.

validate: is gold for config files: it runs the validator (here nginx -t -c <rendered-tmp-file>) before Ansible moves the file into place, so a broken template doesn't break the live service.


11. Handlers and notifications

A handler is a task that runs only if notified by a previous task that reported changed. The canonical use: restart-on-change.

- name: Configure nginx
  hosts: web
  become: true

  handlers:
    - name: Reload nginx
      ansible.builtin.service:
        name: nginx
        state: reloaded

  tasks:
    - name: Render nginx site config
      ansible.builtin.template:
        src: site.conf.j2
        dest: /etc/nginx/conf.d/site.conf
      notify: Reload nginx

Behaviour:

  • Handlers run once, at the end of the play, regardless of how many tasks notified them.
  • If no task in the play changed, the handler doesn't run.
  • If a later task in the play fails, handlers don't run by default — protect critical handlers with force_handlers: true at play level, or call meta: flush_handlers to run them mid-play.

That last gotcha is the #1 source of "but I notified it!" debugging. Remember it.


12. Idempotency in practice

Idempotency is not free. The built-in modules give it to you; ad-hoc shell: doesn't. Common patterns:

  • creates: / removes: on command/shell — only run if the named file is absent / present.
  • changed_when: — override Ansible's change detection (e.g. command that exits 0 isn't necessarily a change).
  • failed_when: — same idea for failure detection.
  • Check mode (--check) — your contract: a task in check mode must not mutate the target. Custom shell: tasks need check_mode: false or a guard, or check mode breaks them.
- name: Extract release archive (only once per version)
  ansible.builtin.unarchive:
    src: "https://releases.example.com/app-{{ app_version }}.tar.gz"
    dest: /opt/app/{{ app_version }}
    remote_src: true
    creates: /opt/app/{{ app_version }}/VERSION

A daily check: run ansible-playbook site.yml --check --diff against prod. If it reports zero changes, your prod hosts match the playbook. If it reports changes, either someone manually mutated the host (drift!) or your playbook isn't truly idempotent.


13. Tags

Tags let you run a subset of a playbook:

tasks:
  - name: Install nginx
    ansible.builtin.package:
      name: nginx
      state: present
    tags: [packages, nginx]

  - name: Render site config
    ansible.builtin.template:
      src: site.conf.j2
      dest: /etc/nginx/conf.d/site.conf
    tags: [config, nginx]
    notify: Reload nginx
ansible-playbook playbooks/site.yml --tags nginx
ansible-playbook playbooks/site.yml --tags config --skip-tags packages
ansible-playbook playbooks/site.yml --list-tags

Two special tags: always (runs unless explicitly skipped) and never (runs only when explicitly requested). Use always sparingly for fact-collecting or setup tasks.


14. Roles

A role is a reusable bundle of tasks + templates + defaults + handlers, organised on disk by convention. Scaffold one with ansible-galaxy:

ansible-galaxy init roles/nginx

That produces:

roles/nginx/
├── defaults/main.yml      # lowest-precedence variable defaults
├── files/                 # static files for the copy module
├── handlers/main.yml      # play-wide handlers
├── meta/main.yml          # role metadata (dependencies, supported platforms)
├── tasks/main.yml         # the default entry point
├── templates/             # Jinja2 templates
├── tests/                 # rarely used; molecule scaffold goes elsewhere
└── vars/main.yml          # role-level vars (higher precedence than defaults)

Call a role from a playbook:

- hosts: web
  become: true
  roles:
    - role: common
    - role: nginx
      vars:
        site_server_name: hiro.example.com

Two design rules that stop role-soup:

  1. Roles are reusable, playbooks are specific. Role: "install nginx, render some site config". Playbook: "configure the hiro web tier". A role should never know about your specific environment.
  2. Defaults > vars > playbook-vars > group_vars > host_vars > -e. Put sensible defaults in defaults/main.yml; let consumers override via roles: block or inventory. Don't hardcode values in tasks/main.yml.

15. Vault — secrets in your repo

Ansible Vault encrypts files (or individual values) with a symmetric password so you can commit them. Reading them at run-time requires the password.

ansible-vault encrypt group_vars/all/vault.yml
ansible-vault edit group_vars/all/vault.yml
ansible-vault decrypt group_vars/all/vault.yml   # rarely; mostly you edit in place

Run plays with vault:

ansible-playbook site.yml --ask-vault-pass
ansible-playbook site.yml --vault-password-file ~/.ansible/vault-pass

Conventions that scale:

  • One vault.yml per group/host: group_vars/prod/vault.yml, group_vars/staging/vault.yml.
  • Inside vault.yml, prefix every variable with vault_: vault_db_password: .... Then in a non-encrypted vars.yml next to it expose db_password: "{{ vault_db_password }}". This trick (the "vault indirection") makes greppable references in your code ({{ db_password }}) while keeping the actual secrets vault-only.
  • Use vault IDs when you have multiple environments with different passwords:
ansible-vault encrypt --vault-id prod@prompt group_vars/prod/vault.yml
ansible-playbook site.yml --vault-id prod@~/.ansible/vault-pass-prod
  • Don't commit the vault password. Store it in your OS keychain, a password manager, or your CI's secret store. Use --vault-password-file <script> where <script> is an executable that prints the password to stdout.

For single values (rare), ansible-vault encrypt_string produces an inline encrypted blob you can paste into a YAML file.


16. ansible.cfg — the knobs that matter

You already saw a sample in §3. The full set worth knowing:

[defaults]
inventory             = inventory/hosts.yml
roles_path            = roles
collections_paths     = ./collections
forks                 = 20                ; concurrent SSH connections
host_key_checking     = False             ; True in prod; pre-populate known_hosts
retry_files_enabled   = False             ; no *.retry files lying around
stdout_callback       = yaml              ; readable diffs
display_skipped_hosts = False
gathering             = smart             ; cache facts within a run
fact_caching          = jsonfile          ; cache across runs
fact_caching_connection = .cache/facts
fact_caching_timeout  = 7200              ; seconds

[ssh_connection]
pipelining            = True              ; the big one — keep it on
ssh_args              = -o ControlMaster=auto -o ControlPersist=60s -o PreferredAuthentications=publickey
control_path          = ~/.ansible/cp/%h-%r

Two switches worth understanding:

  • forks — how many hosts run in parallel. Default 5. Up to 20 is safe for small fleets; beyond 50 your control node CPU/network becomes the bottleneck more often than the targets.
  • pipelining — runs the module payload inline over the SSH stream instead of via a temp file. Free 30%+ speedup. The only reason it's off by default is that some sudoers configs require a TTY (requiretty), which pipelining doesn't allocate. Standard EC2 AMIs don't have requiretty set, so you're fine.

17. Patterns for static AWS EC2

Things specific to your situation — EC2 instances that already exist, public or behind a bastion.

Bastion / jumphost via ~/.ssh/config

Host bastion
  HostName bastion.example.com
  User ec2-user
  IdentityFile ~/.ssh/hiro.pem

Host 10.0.* 172.31.*
  User ec2-user
  IdentityFile ~/.ssh/hiro.pem
  ProxyJump bastion

Now plain ssh 10.0.1.42 works; Ansible inherits the config and just works against private-subnet hosts. Cleaner than ansible_ssh_common_args in YAML.

Key rotation

Don't depend on the SSH keypair that came with the AMI. On first connect, push a fresh key with ansible.posix.authorized_key, then update inventory to use it:

- name: Bootstrap fleet SSH keys
  hosts: all
  become: true
  tasks:
    - name: Ensure ec2-user has the fleet key
      ansible.posix.authorized_key:
        user: ec2-user
        key: "{{ lookup('file', '~/.ssh/hiro-fleet.pub') }}"
        state: present

Run this once with the AMI-launch key; thereafter use the fleet key.

become_method on Ubuntu

Default is sudo, which is fine. If you've locked down sudo to require a password, set become_method: sudo and pass --ask-become-pass. Or use SSH-key-authenticated sudo via NOPASSWD for the Ansible user.

Logging runs

For production runs, send Ansible's output to a timestamped file plus stdout:

ANSIBLE_LOG_PATH=logs/$(date +%Y-%m-%d_%H%M%S).log ansible-playbook playbooks/site.yml

Add logs/ to .gitignore.

Parallelism cap

Set forks based on the SSH connection limit of your control node (ulimit -n) and the bastion. For a fleet of 10–30, forks=20 is good. Beyond 50 hosts, switch to serial: batches in playbooks (rolling deploys) instead of cranking forks.

Smoke-testing after a run

A final play that hits an HTTP endpoint and fails if the response is wrong:

- hosts: web
  tasks:
    - name: Smoke-test the site
      ansible.builtin.uri:
        url: "http://{{ ansible_host }}/healthz"
        return_content: true
      register: health
      failed_when: '"ok" not in health.content'
      tags: smoke

ansible-playbook site.yml --tags smoke then becomes your post-deploy verification.


18. Worked example — nginx on a static EC2 fleet

End-to-end. Two web hosts behind a bastion, Amazon Linux 2023.

infra/
├── ansible.cfg
├── inventory/
│   ├── hosts.yml
│   ├── group_vars/
│   │   ├── all.yml
│   │   └── web.yml
│   └── host_vars/
├── playbooks/
│   ├── site.yml
│   └── bootstrap.yml
└── roles/
    └── nginx/
        ├── defaults/main.yml
        ├── handlers/main.yml
        ├── tasks/main.yml
        └── templates/site.conf.j2

ansible.cfg:

[defaults]
inventory          = inventory/hosts.yml
roles_path         = roles
stdout_callback    = yaml
forks              = 10
host_key_checking  = False
retry_files_enabled = False

[ssh_connection]
pipelining = True
ssh_args   = -o ControlMaster=auto -o ControlPersist=60s

inventory/hosts.yml:

all:
  vars:
    ansible_user: ec2-user
    ansible_ssh_private_key_file: ~/.ssh/hiro.pem
    ansible_ssh_common_args: '-o ProxyJump=ec2-user@bastion.example.com'
    ansible_python_interpreter: /usr/bin/python3
  children:
    web:
      hosts:
        web-01: { ansible_host: 10.0.1.41 }
        web-02: { ansible_host: 10.0.1.42 }

inventory/group_vars/all.yml:

site_server_name: hiro.example.com

inventory/group_vars/web.yml:

nginx_worker_processes: auto

playbooks/site.yml:

- name: Configure web tier
  hosts: web
  become: true
  gather_facts: true
  roles:
    - role: nginx

roles/nginx/defaults/main.yml:

nginx_worker_processes: 1
site_server_name: localhost

roles/nginx/handlers/main.yml:

- name: Reload nginx
  ansible.builtin.service:
    name: nginx
    state: reloaded

roles/nginx/tasks/main.yml:

- name: Install nginx
  ansible.builtin.package:
    name: nginx
    state: present
  tags: [packages]

- name: Ensure /var/www exists
  ansible.builtin.file:
    path: "/var/www/{{ site_server_name }}"
    state: directory
    owner: nginx
    group: nginx
    mode: '0755'

- name: Drop index.html
  ansible.builtin.copy:
    dest: "/var/www/{{ site_server_name }}/index.html"
    content: "Hello from {{ inventory_hostname }}\n"
    owner: nginx
    group: nginx
    mode: '0644'

- name: Render site config
  ansible.builtin.template:
    src: site.conf.j2
    dest: /etc/nginx/conf.d/{{ site_server_name }}.conf
    owner: root
    group: root
    mode: '0644'
    validate: 'nginx -t -c %s'
  notify: Reload nginx
  tags: [config]

- name: Ensure nginx is running and enabled
  ansible.builtin.service:
    name: nginx
    state: started
    enabled: true

roles/nginx/templates/site.conf.j2:

worker_processes {{ nginx_worker_processes }};

server {
    listen 80 default_server;
    server_name {{ site_server_name }};
    root /var/www/{{ site_server_name }};

    location / {
        try_files $uri $uri/ =404;
    }
}

Run order, with verification at each step:

ansible all -m ping                                  # SSH + Python on every host
ansible-playbook playbooks/site.yml --check --diff   # dry-run preview
ansible-playbook playbooks/site.yml                  # apply
ansible-playbook playbooks/site.yml --check --diff   # second run should be clean — idempotency check
ansible-playbook playbooks/site.yml --tags config    # config-only iteration

When that whole pipeline behaves, you have the 80%. Everything else (collections, dynamic inventory, AWX, Molecule, custom modules) is layered on top of these building blocks.


19. What's intentionally out of scope here

Topic Pointer when you need it
EC2 provisioning (create/destroy instances) amazon.aws collection: amazon.aws.ec2_instance, amazon.aws.ec2_key, amazon.aws.ec2_security_group. Prefer Terraform for full lifecycle.
Dynamic inventory (no static hosts.yml) amazon.aws.aws_ec2 inventory plugin: discover instances by tag, region, VPC. Drop a YAML file in inventory/ named *.aws_ec2.yml.
Ansible Tower / AWX Self-hosted UI + RBAC + scheduler. Heavy. Don't reach for it until you have a team and a real fleet.
Molecule (role testing) Docker- or container-based test harness for roles. Useful when roles get pushed to a registry.
Custom modules Python files in library/. 99% of the time a combination of command: + register: is enough.
Containers (community.docker, community.podman) Use when the EC2 host runs Docker / Podman containers; the rest of the playbook structure is identical.
Windows targets WinRM / PowerShell over SSH; different module namespace (ansible.windows.*). Out of scope here.
async: long-running tasks Tasks expected to run > a few minutes (DB migrations, heavy builds). Use async: + poll: to background and check back.
delegate_to: / local_action Run a task on a different host (often the control node — e.g. for DNS updates, slack notifications).

20. Reference card — daily commands

# Sanity checks
ansible all -m ping
ansible-inventory --list                  # show parsed inventory
ansible-inventory --graph                 # group hierarchy
ansible-config dump --only-changed        # what your ansible.cfg actually overrides

# Running
ansible-playbook site.yml
ansible-playbook site.yml --check --diff
ansible-playbook site.yml --limit web-01
ansible-playbook site.yml --tags config --skip-tags packages
ansible-playbook site.yml --start-at-task "Render site config"
ansible-playbook site.yml -vvv            # SSH-level debug

# Vault
ansible-vault encrypt group_vars/prod/vault.yml
ansible-vault edit group_vars/prod/vault.yml
ansible-vault view group_vars/prod/vault.yml
ansible-vault encrypt_string 'hunter2' --name 'api_token'

# Galaxy / collections
ansible-galaxy init roles/<name>
ansible-galaxy collection install amazon.aws

21. How this connects

  • This course's four submodules — Inventory & variables, Roles & collections, Templating & handlers, and Vault & secrets — each go one level deeper than this page's 80/20 pass: the full variable-precedence ladder behind §8, dependency-pulling roles and pinned collections behind §14, macros and --check --diff behind §10–11, and secret rotation across environments behind §15.
  • Terraform (module 07, Terraform, §1) already draws this course's own boundary from its side: "Terraform doesn't enter EC2 instances; it creates them." Read together, §17's static AWS EC2 patterns here are what runs once a terraform apply elsewhere has already produced the fleet this course configures.
  • mise (module 11, mise course page, §4) names this page's §12 idempotency guarantee as its own parallel: a second mise install being a no-op is the same convergence property as a second ansible-playbook run reporting zero changes — one tool converges tool versions, the other converges host state.

That's the 80%. Go run ansible all -m ping against your fleet.