Mission 0.4 · Stage 0 — Configuration Management with Ansible
Roles, Vault, and the Terraform Handoff
Goal: A reusable shoplite_web role, encrypted secrets in Git, a dynamic AWS inventory fed by Terraform tags, and tests for the role.
By the end of this mission
- Refactor a playbook into a role with defaults, tasks, templates, and handlers
- Use Ansible Galaxy collections and roles with a pinned requirements file
- Encrypt secrets with Ansible Vault and keep them in Git safely
- Build inventory from AWS tags instead of hardcoded IPs
- Know where Ansible fits next to Terraform, Packer, Kubernetes, Puppet, Chef, and Salt
Part 1
Understand it first
Roles
A ROLE packages everything needed for one job in a fixed layout: tasks/, handlers/, templates/, files/, defaults/ (weakest variables, meant to be overridden), vars/ (strong, internal), and meta/ (dependencies). The playbook shrinks to 'these hosts get these roles'. Roles are Ansible's version of Terraform modules (Terraform course, Stage 5).
ANSIBLE GALAXY is the public registry for roles and COLLECTIONS (bundles of modules, plugins, and roles, like amazon.aws or community.postgresql). Pin versions in requirements.yml, exactly as you pin providers.
Ansible Vault
Database passwords can't sit in plain group_vars. ansible-vault encrypt turns a file into AES-256 ciphertext that's safe to commit; playbooks decrypt it at runtime with a password (from a prompt, a file, or a script that reads a secret manager). The common pattern is vault.yml holding vault_db_password, and a plain vars.yml saying db_password: "{{ vault_db_password }}", so you can still grep for where a variable is used.
For cloud fleets, reading from AWS Secrets Manager at runtime (amazon.aws.aws_secret lookup) avoids distributing a vault password at all. This is the same idea as the External Secrets Operator in Stage 2.
Terraform creates, Ansible configures
Terraform is good at creating resources and bad at configuring what's inside a server; Ansible is the reverse. The clean handoff is TAGS: Terraform tags instances (Role=web, Env=prod), and Ansible's aws_ec2 inventory plugin queries AWS and builds groups from those tags. No IP ever gets copied by hand.
Where Ansible fits today: use it for VMs, bastions, databases on EC2, network devices, and one-off operational runbooks. For Kubernetes workloads, don't configure containers with Ansible. Bake images with a Dockerfile (Docker course) and deploy them with GitOps (the rest of this course). For golden AMIs, Packer often runs Ansible once at build time, so servers boot already configured (immutable infrastructure).
Puppet, Chef, and Salt
You'll meet these in older estates. PUPPET: declarative DSL, agent pulls from a master every 30 minutes, strong continuous drift correction. CHEF: Ruby-based 'recipes', agent-based, very flexible, popular with developer-heavy teams. SALT (SaltStack): fast event-driven agents (minions) over a message bus, plus an agentless SSH mode. Ansible won most new work because it's agentless and YAML, but agent-based tools enforce state continuously, which Ansible only does if you schedule it (cron, AWX/AAP, or ansible-pull).
Part 2
Your project after this mission · 11 files change
- ansible/
- group_vars/
- prod/
- vars.ymlnew
- vault.ymlnew
- roles/
- shoplite_web/
- defaults/
- main.ymlnew
- handlers/
- main.ymlnew
- molecule/
- default/
- molecule.ymlnew
- tasks/
- main.ymlnew
- templates/
- app.env.j2new
- shoplite.conf.j2new
- inventory.aws_ec2.ymlnew
- requirements.ymlnew
- site.ymlmodified
Part 3
Build it, step by step
- 1
Scaffold the role
ansible-galaxy role initcreates the standard layout. Move the template intoroles/shoplite_web/templates/and the handler intohandlers/main.yml. Anything a user of the role might want to change goes indefaults/main.yml.terminal$ ansible-galaxy role init roles/shoplite_webgit mv templates/shoplite.conf.j2 roles/shoplite_web/templates/── expected output ──- Role roles/shoplite_web was created successfully - 2
Role defaults and tasks
Defaults document every knob the role exposes. Move the
Reload nginxhandler intohandlers/main.ymlunchanged.ansible/roles/shoplite_web/defaults/main.ymlwhole fileyaml listen_port: 80 client_max_body_size: 10m upstreams: [127.0.0.1:8080] admin_users: [] - 3
Role tasks
The tasks are the ones from site.yml, plus a new
app.env.j2template (one line:DB_PASSWORD={{ db_password | default('') }}) for the app's database settings.no_log: truekeeps its contents out of the output.ansible/roles/shoplite_web/tasks/main.ymlwhole fileyaml - name: Install nginx ansible.builtin.apt: { name: nginx, state: present, update_cache: true, cache_valid_time: 3600 } - name: Render the ShopLite site ansible.builtin.template: src: shoplite.conf.j2 dest: /etc/nginx/sites-available/default mode: "0644" notify: Reload nginx - name: Create admin users ansible.builtin.user: { name: "{{ item }}", groups: sudo, append: true, shell: /bin/bash } loop: "{{ admin_users }}" - name: Create the app config directory ansible.builtin.file: { path: /etc/shoplite, state: directory, mode: "0750" } - name: Write the app's DB config ansible.builtin.template: src: app.env.j2 dest: /etc/shoplite/app.env mode: "0600" when: db_password is defined no_log: true - name: Ensure nginx is running ansible.builtin.service: { name: nginx, state: started, enabled: true } - 4
Shrink the playbook
The playbook now reads like a table of contents. Adding a
dbgroup later means one more play with ashoplite_dbrole.ansible/site.ymlwhole fileyaml - name: Configure ShopLite web servers hosts: web become: true roles: - shoplite_web - 5
Pin external collections
Install into the project so every teammate and CI runner uses the same versions.
ansible/requirements.ymlwhole fileyaml collections: - name: amazon.aws version: "10.1.0" - name: community.docker version: "4.7.0" - 6
Encrypt the prod DB password
Split
group_vars/prod.ymlinto a directory:vars.yml(plain) andvault.yml(encrypted). The vault password file lives OUTSIDE the repo and is listed in.gitignore; in CI it comes from a secret.terminal$ mkdir -p group_vars/prod && git mv group_vars/prod.yml group_vars/prod/vars.ymlecho 'vault_db_password: "s3cr3t-prod-pw"' > group_vars/prod/vault.ymlansible-vault encrypt group_vars/prod/vault.yml --vault-password-file ~/.shoplite-vault-passhead -2 group_vars/prod/vault.yml── expected output ──Encryption successful$ANSIBLE_VAULT;1.1;AES2566231356462663934353738653662333762316335386430623961326134393432... - 7
Reference the vaulted value from plain vars
Add this line to
group_vars/prod/vars.yml. Thevault_prefix makes it obvious where the real value lives. Run with--vault-password-file(or setvault_password_filein ansible.cfg).ansible/group_vars/prod/vars.ymladd to fileyaml db_password: "{{ vault_db_password }}"terminal$ ansible-playbook site.yml --vault-password-file ~/.shoplite-vault-passansible-vault view group_vars/prod/vault.yml --vault-password-file ~/.shoplite-vault-pass── expected output ──TASK [shoplite_web : Write the app's DB config] ***skipping: [web1]changed: [web2]...vault_db_password: "s3cr3t-prod-pw" - 8
Dynamic inventory from Terraform's tags
In the Terraform course your instances got
RoleandEnvtags. This inventory file asks EC2 for running instances with those tags and turns them into groups (role_web,env_prod). Connection uses SSM Session Manager or SSH to private IPs through a bastion. No public IPs needed (AWS course, Topic 2.2).ansible/inventory.aws_ec2.ymlwhole fileyaml plugin: amazon.aws.aws_ec2 regions: [ap-south-1] filters: instance-state-name: running tag:Project: shoplite keyed_groups: - key: tags.Role prefix: role - key: tags.Env prefix: env hostnames: [tag:Name, private-ip-address] compose: ansible_host: private_ip_addressterminal$ ansible-galaxy collection install -r requirements.ymlansible-inventory -i inventory.aws_ec2.yml --graph── expected output ──@all:|--@aws_ec2:| |--shoplite-prod-web-0| |--shoplite-prod-web-1|--@role_web:| |--shoplite-prod-web-0| |--shoplite-prod-web-1|--@env_prod:| |--shoplite-prod-web-0| |--shoplite-prod-web-1 - 9
Test the role with Molecule
Molecule spins up a throwaway container, applies the role, runs it again to prove idempotency (the run fails if anything reports
changed), runs verification, and destroys everything. Putmolecule testin CI and roles stay trustworthy, just liketerraform testfor modules.ansible/roles/shoplite_web/molecule/default/molecule.ymlwhole fileyaml driver: name: docker platforms: - name: instance image: geerlingguy/docker-ubuntu2404-ansible pre_build_image: true provisioner: name: ansible verifier: name: ansibleterminal$ cd roles/shoplite_web && molecule test── expected output ──INFO default ➜ converge: Executed: SuccessfulINFO default ➜ idempotence: Idempotence completed successfully.INFO default ➜ verify: Executed: SuccessfulINFO default ➜ destroy: Executed: Successful
Checkpoint — you should now have
- ✓site.yml is five lines and still configures both environments.
- ✓
group_vars/prod/vault.ymlis ciphertext in Git, and the vault password file is not in the repo. - ✓
ansible-inventory -i inventory.aws_ec2.yml --graphlists instances by tag (if you ran the EC2 part). - ✓
molecule testpasses, including the idempotence step.
Part 4
Break it on purpose
Make each change, run the command, and read the error before revealing the diagnosis. Recognising these messages on sight is what makes you fast on a real team. Undo the change afterwards.
Break #1
Leak a secret in the logs
Remove no_log: true from the DB config task and run with -v.
Break #2
Run without the vault password
Run ansible-playbook site.yml without --vault-password-file.
Part 5
Interview questions from this mission
Ansible vs Terraform: when do you use which?
Push vs pull configuration management?
How do you manage secrets in Ansible?
Before you stop