
Last week was Terraform. This week was the other half of the pair: Ansible. Six assignments took me from an empty Windows laptop to a deployment that gets a risk review before anything is applied. Along the way, I got to practice a skill that's rarely in the tutorials: reading the error, finding the real cause, and fixing that instead of the symptom.
Part 1: A workstation that works the same for everyone
The first assignment was the least glamorous and probably the most useful. Ansible can't run natively on Windows, so the controller lives in Ubuntu under WSL. I installed Ansible, ansible-lint, yamllint and pre-commit inside a Python virtual environment, pinned the versions in requirements.txt, and set up SSH keys, an agent, and Git hooks that lint every YAML file before a commit.
Four small problems each taught me something:
- My project was on the Windows drive. From Linux, every file there looks world-writable, and Ansible deliberately ignores an
ansible.cfgin a world-writable folder. It doesn't warn you loudly, it just runs with defaults. Moving the project into the Linux home folder fixed it. - A hidden character broke the pre-commit config. I had generated a version number with a command that prints in colour, and the colour codes ended up inside the file.
- A hook pinned Python 3.14, which Ubuntu 24.04 doesn't have. One
language_versionline in my config overrode it. - The newest Ansible dropped the
yamloutput plugin, so I usedcallback_result_format = yamlfor the same readable output.
None of it was hard once I knew where to look. Reading the actual error was the skill.
Part 2: Four servers and one command
The second assignment used Terraform to build four Ubuntu servers (web1, web2, app1 and db1) from a single for_each, with two security groups: SSH only from my controller's IP, and HTTP only on the web hosts. Then I generated the Ansible inventory straight from Terraform's output with jq, so no IP address was ever typed by hand.
ansible all -i inventory.ini -m ping ansible web -i inventory.ini -m apt -a "name=nginx state=present" --become One command, four servers. The first SSH attempt timed out, even though everything was running. My home network's public IP had changed since I applied the Terraform, so my own security group was correctly turning me away. I updated one variable, and Terraform changed only the firewall rules in place.
Part 3: One playbook, three plays
The third assignment deployed a static website with a multi-play playbook: install Nginx, copy the site and reload Nginx only if it changed, then verify from the controller that both servers return HTTP 200. The first run changed things. The second run showed changed=0, which is what idempotency looks like.
One detail made that work: the apt cache task reports a change on every run unless you give it a cache_valid_time. When I tried to commit, my pre-commit hook rejected the playbook because the file had no newline at the end. The playbook was fine. The linter was doing its job.
Part 4: Terraform and Ansible together
The fourth assignment tied them together: Terraform builds the server, and Ansible deploys the Mini Finance site, with the server's IP flowing from one tool to the other.
- AWS refused a security group name starting with
sg-, because that prefix is reserved for real IDs. I renamed the group and kept the name in a tag. - The playbook's first run failed at the clone step, with Git asking for a username. That's how Git reports a repository that doesn't exist or isn't public, and I had a slightly wrong URL. Play 1 had already succeeded, so I knew exactly where to look.
- To copy files from the cloned repository into the web root with
rsync, I delegated the task to the server itself. I read the module's source to confirm that same-host delegation runs rsync locally, and then turned off its owner and group copying, so re-runs would report no changes.
Part 5: Roles, and a database nobody can reach
The fifth assignment split the deployment into three Ansible roles (common, nginx and epicbook) and added a managed MySQL database on Amazon RDS. The database sits in private subnets with no public access, and its security group has no IP range at all: it allows port 3306 only from the web server's security group.
The part I care most about is the password. It's never written to a file. I type it into my terminal once, Terraform and Ansible both read it from the environment, Terraform stores it as a write-only value so it isn't in state or outputs, and the Ansible tasks that use it are marked no_log.
The database import checks before it acts. It loads the schema only if the tables are missing and the seed data only if they're empty, so running the playbook again doesn't fail on "table already exists". The application runs under PM2 behind an Nginx reverse proxy, and I verified it from the outside: HTTP 200 with an Express header behind Nginx, and a POST to the cart API that created a row in the managed database.
Part 6: An AI reviewer that isn't allowed to press apply
The last assignment was the most interesting. I built a change-risk review for the playbook, following one loop: gather, analyze, a human decides, then verify.
- Gather: a Bash script runs
ansible-playbook --check --diff, a dry run that simulates every task without changing the server. It pulls out the tasks that would change and sorts them into four risk categories, with exit code 0 for healthy, 1 for a warning and 2 for a failure. - Analyze: a Claude Code skill runs the script, reads the report and explains the risk. It can read files and run the script, but it has no tool to edit anything, and the project settings stop it from running the real playbook, SSH, or Terraform apply and destroy at all.
- Decide and verify: I read the report, apply the playbook myself, and then run the review again.
The test change was one deliberate risky task, an Nginx restart. The script flagged it as a service impact, and Claude explained what it meant (the site would briefly drop connections) and kept it separate from an older warning that was already in my baseline. I applied it myself.
Three things surprised me:
- A dry run can't see
commandandshelltasks. They're skipped, so a clean report doesn't mean they're safe. The report says so, and so does Claude. - A restart is never idempotent. It kept showing up as a change even after I'd applied it. The review wasn't wrong: it was telling me the task was redundant, because my role already reloads Nginx through a handler. I removed it, and the next review came back healthy.
- When my password variable was missing, the script reported a failure and the skill stopped, instead of calling the five empty checks "safe". That's the rule working: no claims without evidence.
What I'm taking away
- Read the failing task, not the whole log. Almost every problem this week was solved by finding the one line that failed and asking what it assumed.
- Idempotency is a habit, not a feature. Small settings like
cache_valid_time,creates, and checks before imports decide whether a second run is truly clean. - Keep secrets out of everything. Environment variables, write-only arguments,
no_log, and.gitignore. - Test the dry run's blind spots. A tool that reports "nothing would change" is only as good as what it can see.
- The human still presses the button. AI is a fast, careful reviewer, but it can't see your live system, and it shouldn't hold the keys.
Then terraform destroy removed everything, because the most important command of every lab is the last one.
P.S. This post is part of the DevOps Micro Internship (DMI) with Agentic AI, Cohort 3, by Pravin Mishra. You can follow my graded progress throughout the cohort. Start your DevOps journey: https://dmi.pravinmishra.com/?utm_source=student&utm_medium=ps-blog&utm_campaign=cohort3


