A pre-production checklist, operational best practices, operational monitoring, disaster recovery, and continuous improvement to take your Ansible automation to production-grade level — the final episode of the Learn Ansible series.

After episode 29, where we covered scaling Ansible for enterprise — centralized architecture with AAP/AWX, GitOps repositories, inventory/playbook/role separation, and performance optimization for thousands of hosts — you now have a picture of how Ansible runs at organizational scale. But there's a more fundamental question that's often forgotten: how do we know the automation is truly ready to run in production without causing a catastrophe?
The answer isn't in a single magic feature, but in process and discipline. A well-written playbook that's run without testing, without code review, and without documentation is still dangerous — like a pilot who flies the plane smoothly but forgets to run the pre-flight checklist.
Episode 30 is the final episode of the Learn Ansible series. After 29 episodes building the foundation from zero — starting with agentless architecture, playbooks, roles, all the way to enterprise scaling — it's time to summarize all those lessons into one complete package: the production deployment checklist and best practices. In this episode we'll cover the pre-production gateway, operational best practices, common mistakes to avoid, operational monitoring, disaster recovery, and continuous improvement. This isn't just theory: this is how a healthy infrastructure team works in the real world.
The best analogy for a pre-production checklist is the pilot's checklist before takeoff. A pilot doesn't argue with the checklist; they run it sequentially, step by step, because they know one missed item could mean the difference between a normal flight and a crash. The Ansible playbooks you write can also touch hundreds of servers at once — and one wrong line of YAML can take down a service entirely.
Here's the pre-production gateway you must pass every time you deploy a change to a production environment:
| No | Checklist Item | Tool / Method | Purpose |
|---|---|---|---|
| 1 | Code review completed and approved | Pull Request in GitHub/GitLab | Ensure the logic is verified by another engineer, not just its author |
| 2 | Linting passes without errors | ansible-lint | Enforce best practices, FQCN, and static security (episode 18) |
| 3 | Automated testing done | Molecule + Testinfra | Test roles in an isolated environment before touching real hosts (episode 18) |
| 4 | Documentation up-to-date | README, runbook, variable comments | Others (and you 6 months from now) can run & troubleshoot |
| 5 | Secrets encrypted with Vault | ansible-vault + separate vault IDs per environment | No passwords/API keys in plaintext in Git (episode 14) |
| 6 | Inventory validated | ansible-inventory --list --yaml | Hosts, variables, and groups structured correctly before execution |
| 7 | Dry-run testing passed | ansible-playbook --check --diff | Ensure the changes to be made match exactly what's expected |
Important
Don't make this checklist optional. In professional teams, this checklist is enforced by the CI/CD pipeline (episode 19): if ansible-lint finds a violation or Molecule fails, the PR must not be merged and the deployment must not run. Machines enforcing discipline are always more reliable than human good intentions.
A tidy directory structure is the first documentation people will read. A healthy production project usually looks like this:
ansible-prod/
├── ansible.cfg # konfigurasi global (forks, callback, ssh)
├── requirements.yml # dependensi collection (episode 13)
├── inventory/
│ ├── production.yml # inventory production
│ └── staging.yml # inventory staging
├── group_vars/
│ ├── production/
│ │ ├── all.yml # variabel bersama environment production
│ │ └── webservers.yml # variabel spesifik group
│ └── staging/
├── host_vars/ # variabel per-host
├── playbooks/
│ ├── site.yml # playbook utama / entry point
│ ├── deploy-app.yml # playbook deploy & rollback
│ └── maintenance.yml
├── roles/
│ ├── nginx/
│ ├── postgresql/
│ └── hardened/ # security hardening (episode 27)
├── vault/
│ ├── production.vault.yml # secret terenkripsi per environment
│ └── staging.vault.yml
├── docs/
│ ├── README.md # cara menjalankan project
│ └── runbooks/ # prosedur insiden & recovery
├── .github/workflows/
│ ├── lint-test.yml # CI: lint + Molecule
│ └── deploy.yml # CD: deploy ke staging/production
└── .ansible-lint # konfigurasi ansible-lintNotice the strict separation between inventory/, group_vars/, playbooks/, and roles/. This is the separation of concerns principle we covered in episode 29 — data separated from logic, so one role can be reused across many environments just by swapping inventory and variables.
--check + --diff Workflow: Dry-Run Before Touching ProductionThe two flags that most often save infrastructure are --check and --diff. --check (dry-run) simulates execution without making real changes, while --diff shows the text changes that would be made to configuration files. Their combination gives you a "change map" before actually executing.
ansible-playbook -i inventory/production.yml playbooks/site.yml \
--check --diff --limit webservers --tags configTip
Use --limit webservers to restrict the dry-run to a subset of hosts first, and --tags config to test only one layer of changes. You can also add --list-tasks --list-hosts to see a summary of tasks and hosts that will be affected before running anything.
After passing the pre-production gateway, you'll run and maintain this automation every day. Here are operational principles that keep automation healthy long-term.
All Ansible assets must be in Git: playbooks, roles, inventory (without secrets), ansible.cfg, requirements, even runbook documentation. This isn't just a backup — it's an audit trail (episode 19). If production breaks, you can answer the question: what change caused this? who wrote it? when? Without Git, that question can only be answered by guessing.
git checkout -b fix/nginx-reload-handler
# ... perbaiki playbook ...
ansible-lint . # 1. lint
molecule test -s production # 2. test
git add playbooks/ roles/
git commit -m "fix: pindahkan reload handler ke role nginx"
git push -u origin fix/nginx-reload-handler
# 3. buka PR → code review → merge → pipeline deployDon't rewrite the same logic in every playbook. Wrap reusable functionality into roles (episode 12) and use collections from Galaxy (episode 13) for common things. A mature nginx role can be used in dev, staging, and production just by changing variables — not by copy-pasting 200 lines of YAML. This saves time and, more importantly, reduces the bug surface.
Recall the lessons from episode 10: use block/rescue/always to handle failures gracefully, failed_when to set the right failure conditions, and ignore_errors only with a clear reason. A playbook that gives up midway without a clear message is an operator's worst enemy while handling an incident at night.
Large playbooks that run everything every time are wasteful and risky. Use tags to break playbooks into units that can be executed selectively — for example, only updating configuration without restarting, or only pulling new artifacts without touching system configuration.
- name: Deploy web server production
hosts: webservers
become: true
vars:
app_version: "1.4.2"
app_domain: "app.example.com"
tasks:
- name: Install Nginx
ansible.builtin.apt:
name: nginx
state: present
tags: install
- name: Salin konfigurasi virtual host
ansible.builtin.template:
src: vhost.conf.j2
dest: "/etc/nginx/conf.d/{{ app_domain }}.conf"
mode: "0644"
notify: reload nginx
tags: config
- name: Deploy artefak aplikasi
ansible.builtin.copy:
src: "build/app-{{ app_version }}.tar.gz"
dest: "/var/www/{{ app_domain }}/"
tags: deploy
handlers:
- name: reload nginx
ansible.builtin.systemd_service:
name: nginx
state: reloadedNow you can run different layers of changes independently:
ansible-playbook -i inventory/production.yml playbooks/webserver.yml --tags installWarning
Tags are a double-edged sword. If you tag tasks with many tags, make sure the tags genuinely cover all tasks needed for a safe operation. Skipping one seemingly unimportant task (e.g., health-check verification) could result in a deployment "succeeding" on top of a dead service.
Idempotency has been Ansible's DNA since episode 1, and in production it's no longer a nice-to-have but an absolute requirement. An idempotent playbook can be rerun without side effects, so it's safe to be triggered by cron, retried after a failure, or run twice by a CI pipeline. If you find a task that's always changed every run, that's a signal something isn't declarative — for example, using shell for something that should use a dedicated module, or writing a random timestamp into a config file.
Don't Repeat Yourself. Don't hardcode the same value in ten places. Store values that change between environments in group_vars/ and host_vars/ (episode 7), then make playbooks pure logic that reads variables:
domain: "app.example.com"
nginx_worker_processes: "auto"
nginx_ssl_cert: "/etc/ssl/certs/app.example.com.crt"
backend_ports: [8080, 8081, 8082]
monitoring_enabled: trueThe playbook then just references {{ domain }} and {{ nginx_worker_processes }} without needing to know their values — an environment change is done in one variable file, not by editing the playbook.
A variable without documentation is a puzzle. In every role, provide a defaults/main.yml with short comments, and write a concise README explaining required variables, role dependencies, and usage examples. Runbooks for important operations (deploy, rollback, backup restore) should be written as if they'll be read by someone who joined last week — because one day they will.
After writing many playbooks in production, these error patterns appear over and over. Recognize their signs so you can avoid them early:
| Mistake | Why It's Dangerous | Solution |
|---|---|---|
Using command/shell when a module exists | Not idempotent, error-prone, uninformative logs | Always look for a dedicated module first (apt, file, copy, template, etc.) |
| Hardcoding values in playbooks | Playbooks not portable, hard to change, hard to understand | Move to group_vars/host_vars (DRY principle) |
| Ignoring idempotency | Reruns trigger unexpected changes | Test with --check, watch for tasks that are always changed |
| Missing error handling | Playbook stops silently, no cleanup | block/rescue/always, failed_when, changed_when |
| Testing directly in production | One bug destroys hundreds of servers | Test in staging first (Molecule + staging environment) |
| Poor secret management | Passwords & API keys leak to Git / logs | ansible-vault + no_log: true + vault IDs per environment |
| Playbooks too complex | Hard to review, test, and maintain | Split into roles; one playbook does one thing well |
| Missing documentation | Reliance on one person's memory | README + runbooks + comments in defaults/main.yml |
One of the most common cases is using shell for package installation. Notice the following diff — converting it to a dedicated module makes the playbook idempotent and readable:
- name: Install Nginx (CARA LAMA - salah)
ansible.builtin.shell: apt-get update && apt-get install -y nginx
- name: Install Nginx (CARA BENAR)
ansible.builtin.apt:
name: nginx
state: presentCaution
A simple rule of thumb: if you find yourself writing command/shell to do something, stop and look for the right module. The apt, dnf, file, copy, template, systemd_service, git, and get_url modules already handle almost all daily needs. shell is only for cases where there's genuinely no module.
Automation that isn't observed is just hope. In production, you must know the answers to these questions: how long do playbooks take? how many succeed and fail? what actually changed? who ran what and when?
Here are the key metrics you need to monitor:
| Metric | How to Measure | Tool / Reference | Target Value |
|---|---|---|---|
| Playbook execution time | timer & profile_tasks callbacks, job duration in AWX/AAP | callbacks_enabled = timer, profile_tasks | Stable & trending down with optimization |
| Success / failure rate | Job status from AWX API, CI/CD logs, exit codes | AWX Job API, Prometheus exporter | Success > 99%, no sudden failed |
Change rate (changed ratio) | Percentage of tasks with changed status per run | json callback, diff review | Decreasing as playbooks mature → "converged" |
| Host coverage | Reachable host count vs total inventory | ansible-playbook --list-hosts, AAP reports | 100% with no unreachable |
| Audit trail | Who ran what, when, from where | AWX/AAP job history, callback logs | 100% of executions recorded |
To build an analyzable trail, enable callbacks that emit structured output:
[defaults]
stdout_callback = json
callbacks_enabled = timer, profile_tasksWith stdout_callback = json, playbook output can be parsed by other tooling. Timer records total duration per task, and profile_tasks shows which tasks are slowest — valuable data for performance optimization. At enterprise scale, AWX/AAP (episode 20) already provides job history, RBAC, and a REST API that can be integrated into observability dashboards (episode 25) to trigger alerts if a playbook starts failing repeatedly.
Tip
One very useful practice: record the change rate of every run. A "mature" playbook should change things less and less — meaning the system has converged to the desired state. A sudden spike in changed almost always indicates configuration drift that must be investigated.
Accept the reality: someday something will fail. A playbook will delete the wrong file, a release will break, or a production server will be compromised. The question isn't whether it happens, but how fast you can recover.
What needs to be backed up is the source of truth of your automation:
ansible.cfg and requirements — global config and the version-pinned collection list (for reproducibility).Important
A backup without a restore test isn't a backup — it's just storage. The restore procedure must be tested periodically, at least quarterly, in an isolated environment. A backup that can't be restored is worth the same as having no backup at all.
The most reliable rollback pattern is based on deploying the same way as rolling back — use tags to mark the relevant tasks, and make the target version a variable that can be overridden at run time:
- name: Deploy / rollback aplikasi
hosts: appservers
become: true
vars:
target_version: "1.4.2"
tasks:
- name: Unduh artefak versi target
ansible.builtin.get_url:
url: "https://artifacts.internal/app-{{ target_version }}.tar.gz"
dest: "/opt/app/app-{{ target_version }}.tar.gz"
tags: [deploy, rollback]
- name: Arahkan symlink ke versi target
ansible.builtin.file:
src: "/opt/app/releases/app-{{ target_version }}"
dest: /opt/app/current
state: link
notify: restart app
tags: [deploy, rollback]
handlers:
- name: restart app
ansible.builtin.systemd_service:
name: app
state: restartedWhen version 1.4.3 has problems, a rollback is just one command — without changing a single line of the playbook:
ansible-playbook -i inventory/production.yml playbooks/deploy-app.yml \
--tags rollback -e target_version=1.4.2Note
The key to this pattern is immutable deployment: every application version is stored as a separate directory and the current symlink just gets repointed. Rollback becomes a fast, safe operation that can be triggered anytime — not dependent on a complicated, error-prone reverse process.
Even the best recovery plan means nothing if it's never tested. Schedule routine drills: restore a database backup on a staging host, roll back a release, rebuild a control node from scratch from the Git repository. Record the results in a runbook and fix procedures that fail. Teams that regularly practice recovery are far calmer and faster when a real incident happens.
Automation isn't a one-shot project; it's a living system that must be maintained. Here are habits that keep automation quality rising over time:
with_items → loop), and ansible-lint will help detect it.profile_tasks callback (episode 15) to find bottlenecks; disable unneeded fact gathering, leverage fact caching, and tune forks appropriately.ansible-core and collections on a schedule, monitor security advisories, and run security hardening periodically (episode 27).Congratulations — you've completed the long journey from episode 0 to episode 30! Let's briefly recap the big map we've traveled together:
| Phase | Episodes | Core Content |
|---|---|---|
| Pre-Requisites & Fundamentals | 0-2 | Environment setup, automation history, Ansible agentless architecture |
| Basic Operational & Core Concepts | 3-10 | Inventory, ad-hoc, playbook, handler, variables & facts, Jinja2, control flow, error handling |
| Networking, Security & Reusability | 11-14 | Includes/imports, roles, collections, Ansible Vault |
| Advanced Topics & Optimization | 15-17 | Performance tuning, async actions, custom modules & filters |
| Modern Ecosystem & Production Readiness | 18-30 | Lint & Molecule, CI/CD, execution environments, dynamic inventory, network/k8s/database/monitoring/cloud/security/Windows automation, enterprise scaling, and this production checklist |
From just understanding ansible -m ping in episode 0, you now have the ability to automate Linux and Windows servers, networks, multi-vendor clouds, Kubernetes, and databases — with quality maintained by linting, testing, and CI/CD pipelines, and executed safely at enterprise scale. That's a rare capability with very high value in the DevOps and SRE job market.
Remember the three core principles that have always guided us: idempotency, declarative thinking, and automation over documentation — write what you do, and make machines do what you write.
The journey doesn't stop here. After completing this series, here are the next steps you can take to master Ansible even deeper:
ansible-navigator, and Red Hat certification (EX294/RHCE) to validate your skills.Automation is an investment that keeps paying compound interest: every hour you spend writing good automation will save hundreds of hours in the future. Keep automating, keep practicing, and make infrastructure something you can rebuild with a single command. See you on the next journey — and happy building as a true infrastructure engineer!