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
changedonly when it actually altered the host, so a second run of the same playbook should come back allok— 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:
- 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.
- 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.
- Everything runs over SSH. If you can
ssh ec2-user@10.0.1.42you canansibleit. 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_usermatters. Amazon Linux 2023 →ec2-user. Ubuntu →ubuntu. Debian →admin. Rocky/RHEL →ec2-userorrocky. Different AMI ⇒ override at host level.ansible_host≠ host name. The key (web-01) is the inventory name you use everywhere else;ansible_hostis 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.skipped—when: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:
- Variables — anything you set. Live in: inventory (
group_vars,host_vars), playbookvars:, roledefaults:/vars:, command-line-e,set_fact:. - Facts — gathered from the target at the start of the play (when
gather_facts: true). Things likeansible_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: trueat play level, or callmeta: flush_handlersto 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:oncommand/shell— only run if the named file is absent / present.changed_when:— override Ansible's change detection (e.g.commandthat 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. Customshell:tasks needcheck_mode: falseor 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:
- 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.
- Defaults > vars > playbook-vars > group_vars > host_vars > -e. Put sensible defaults in
defaults/main.yml; let consumers override viaroles:block or inventory. Don't hardcode values intasks/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.ymlper group/host:group_vars/prod/vault.yml,group_vars/staging/vault.yml. - Inside
vault.yml, prefix every variable withvault_:vault_db_password: .... Then in a non-encryptedvars.ymlnext to it exposedb_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 somesudoersconfigs require a TTY (requiretty), which pipelining doesn't allocate. Standard EC2 AMIs don't haverequirettyset, 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 --diffbehind §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 applyelsewhere 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 installbeing a no-op is the same convergence property as a secondansible-playbookrun 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.