The single most powerful Ansible discipline is: one playbook, many inventories.
The playbook code is identical between environments. What differs is the inventory
file (different hosts) and the variables (different sizes, different secrets, different
credentials). Promotion from staging to production is a one-line change to the -i
flag.
my-project/
├── ansible.cfg
├── site.yml # ONE playbook, all environments
├── roles/
│ ├── common/
│ ├── mysql/
│ └── monitoring/
├── inventories/
│ ├── dev/
│ │ ├── hosts.ini # 2 dev hosts
│ │ ├── group_vars/
│ │ │ ├── all.yml # dev-wide vars
│ │ │ ├── databases.yml
│ │ │ └── vault.yml # encrypted with dev vault password
│ │ └── host_vars/
│ ├── staging/
│ │ ├── hosts.ini # 6 staging hosts
│ │ ├── group_vars/
│ │ │ ├── all.yml
│ │ │ ├── databases.yml
│ │ │ └── vault.yml # encrypted with staging vault password
│ │ └── host_vars/
│ └── production/
│ ├── hosts.ini # 50 prod hosts
│ ├── group_vars/
│ │ ├── all.yml
│ │ ├── databases.yml
│ │ └── vault.yml # encrypted with prod vault password
│ └── host_vars/
└── playbooks/
├── deploy.yml
├── rolling_restart.yml
└── snapshot.yml
Same variable name, different value per environment. Playbook code reads
{{ mysql_max_connections }} — Ansible resolves it from whichever inventory
you point at.
---
mysql_max_connections: 50
mysql_innodb_buffer_pool_size: "512M"
mysql_databases:
- name: myapp_dev
backup_retention_days: 1
mysql_host_count: 1
---
mysql_max_connections: 200
mysql_innodb_buffer_pool_size: "2G"
mysql_databases:
- name: myapp_staging
backup_retention_days: 7
mysql_host_count: 3
---
mysql_max_connections: 800
mysql_innodb_buffer_pool_size: "16G"
mysql_databases:
- name: myapp_prod
backup_retention_days: 30
mysql_host_count: 9
require_ssl: true
require_audit_log: true
Each environment gets its own vault password. A junior dev with the dev vault password CAN'T decrypt staging or production secrets even if they have the repo.
# Generate three vault passwords
openssl rand -base64 32 > ~/.ansible/vault_pass_dev
openssl rand -base64 32 > ~/.ansible/vault_pass_staging
openssl rand -base64 32 > ~/.ansible/vault_pass_prod
chmod 600 ~/.ansible/vault_pass_*
# Encrypt each environment's vault.yml with the matching password + id
ansible-vault encrypt --vault-id dev@~/.ansible/vault_pass_dev \
inventories/dev/group_vars/vault.yml
ansible-vault encrypt --vault-id staging@~/.ansible/vault_pass_staging \
inventories/staging/group_vars/vault.yml
ansible-vault encrypt --vault-id prod@~/.ansible/vault_pass_prod \
inventories/production/group_vars/vault.yml
# Each file is tagged with which vault id it needs
head -1 inventories/dev/group_vars/vault.yml
# $ANSIBLE_VAULT;1.2;AES256;dev
head -1 inventories/production/group_vars/vault.yml
# $ANSIBLE_VAULT;1.2;AES256;prod
[defaults]
vault_identity_list = dev@~/.ansible/vault_pass_dev,
staging@~/.ansible/vault_pass_staging,
prod@~/.ansible/vault_pass_prod
# Ansible automatically picks the right vault password based on the file's tag
# Dev (auto-rebuilds nightly, safe to break)
ansible-playbook -i inventories/dev/ site.yml
# Staging (prod-shaped, used for changes before prod)
ansible-playbook -i inventories/staging/ site.yml --check --diff
# Production (manual confirmation before every run)
ansible-playbook -i inventories/production/ site.yml \
--check --diff # always preview first
ansible-playbook -i inventories/production/ site.yml \
--vault-id prod@.vault_pass_prod # then apply for real
| Step | Action | Approval |
|---|---|---|
| 1 | Developer writes playbook change, tests against dev | None |
| 2 | PR review by another engineer + CI passes | 1 reviewer |
| 3 | Merge to main → auto-deploy to staging via CI | Automatic |
| 4 | Soak in staging for 24h, monitoring dashboards green | Time-based |
| 5 | Manual trigger of production deploy in CI | Change manager |
| 6 | Smoke tests run, observability dashboards watched | On-call confirms |
The single most expensive mistake is running a staging-or-dev intended change against production. Three safety nets that prevent it:
---
- hosts: all
pre_tasks:
- name: Refuse to run if env not declared
ansible.builtin.assert:
that:
- target_env is defined
- target_env in ["dev", "staging", "production"]
fail_msg: "Set -e target_env=<dev|staging|production> when running"
- name: Confirm production runs explicitly
ansible.builtin.pause:
prompt: "About to run against PRODUCTION. Confirm with 'yes' to continue"
when: target_env == "production"
register: confirmation
- name: Abort if confirmation isn't 'yes'
ansible.builtin.fail:
msg: "Production run aborted by operator"
when:
- target_env == "production"
- confirmation.user_input | lower != "yes"
#!/usr/bin/env bash
# bin/deploy
set -euo pipefail
ENV="$1"
shift
case "$ENV" in
dev|staging|production) ;;
*) echo "Usage: $0 <dev|staging|production> [extra ansible args]"; exit 1 ;;
esac
if [[ "$ENV" == "production" ]]; then
read -p "About to deploy to PRODUCTION. Type 'yes' to continue: " confirm
[[ "$confirm" == "yes" ]] || { echo "Aborted."; exit 1; }
fi
exec ansible-playbook \
-i "inventories/$ENV/" \
-e "target_env=$ENV" \
--vault-id "$ENV@~/.ansible/vault_pass_$ENV" \
"$@" \
site.yml
# Usage:
# bin/deploy dev
# bin/deploy staging --tags mysql
# bin/deploy production --check --diff
ansible.cfg's inventory = default to production. Always require -i at the CLI. A misconfigured shell environment that defaulted to production once destroyed a customer's staging-test data — they had to restore from backup.---
stages:
- test
- deploy_staging
- deploy_production
test:
stage: test
script:
- ansible-lint .
- ansible-playbook -i inventories/dev/ site.yml --syntax-check
deploy_staging:
stage: deploy_staging
environment: staging
script:
- echo "$VAULT_PASS_STAGING" > .vault_pass
- ansible-playbook -i inventories/staging/ site.yml \
--vault-id staging@.vault_pass
after_script:
- rm -f .vault_pass
only:
- main
deploy_production:
stage: deploy_production
environment: production
when: manual # human approval required
script:
- echo "$VAULT_PASS_PROD" > .vault_pass
- ansible-playbook -i inventories/production/ site.yml \
--vault-id prod@.vault_pass
after_script:
- rm -f .vault_pass
only:
- main