A beginner-friendly Ansible project that provisions and manages development, staging, and production environments using roles, inventory groups, and environment-specific variables. Docker containers simulate real servers so the entire setup runs locally with zero cloud cost.
- Overview
- Architecture
- Project Structure
- Prerequisites
- Quick Start
- Environments
- Roles
- Playbooks
- How It Works
- Adding a New Environment
- Troubleshooting
This project demonstrates how to use Ansible to manage multiple environments from a single codebase. Each environment (dev, staging, prod) has its own:
- Inventory — defines which servers belong to that environment
- Group vars — environment-specific variables (ports, versions, worker counts)
- Isolated containers — Docker containers act as servers for local testing
The same playbooks and roles run across all environments. Only the variables change.
Your Machine (macOS)
│
├── ansible-playbook -i inventories/dev/ → targets dev containers
├── ansible-playbook -i inventories/staging/ → targets staging containers
└── ansible-playbook -i inventories/prod/ → targets prod containers
Docker Containers (simulated servers)
┌─────────────────────────────────────────────────────────────┐
│ DEV STAGING PROD │
│ ┌──────────────┐ ┌──────────────┐ ┌─────────────┐ │
│ │ dev-web-01 │ │staging-web-01│ │prod-web-01 │ │
│ │ SSH: 2201 │ │ SSH: 2211 │ │ SSH: 2221 │ │
│ │ HTTP: 8080 │ │ HTTP: 8081 │ │ HTTP: 8082 │ │
│ └──────────────┘ └──────────────┘ └─────────────┘ │
│ ┌──────────────┐ ┌──────────────┐ ┌─────────────┐ │
│ │ dev-db-01 │ │staging-db-01 │ │ prod-db-01 │ │
│ │ SSH: 2202 │ │ SSH: 2212 │ │ SSH: 2223 │ │
│ └──────────────┘ └──────────────┘ └─────────────┘ │
└─────────────────────────────────────────────────────────────┘
Ansible connects via SSH → runs roles → configures each server
ansible-multi-env/
├── ansible.cfg # Ansible configuration
├── docker-compose.yml # Defines all 6 containers
├── docker/
│ ├── Dockerfile # Debian SSH + Python image
│ └── authorized_keys # Public key injected into containers
├── ansible_key # Private SSH key (gitignored)
├── ansible_key.pub # Public SSH key
├── inventories/
│ ├── dev/
│ │ ├── hosts.ini # Dev server list
│ │ └── group_vars/
│ │ └── all.yml # Dev variables
│ ├── staging/
│ │ ├── hosts.ini # Staging server list
│ │ └── group_vars/
│ │ └── all.yml # Staging variables
│ └── prod/
│ ├── hosts.ini # Prod server list
│ └── group_vars/
│ └── all.yml # Prod variables
├── roles/
│ ├── common/
│ │ └── tasks/
│ │ └── main.yml # Base setup for all servers
│ ├── webserver/
│ │ ├── tasks/
│ │ │ └── main.yml # Nginx install and config
│ │ ├── handlers/
│ │ │ └── main.yml # Restart Nginx on config change
│ │ └── templates/
│ │ └── nginx.conf.j2 # Nginx config template
│ └── app/
│ ├── tasks/
│ │ └── main.yml # App deployment tasks
│ └── templates/
│ └── index.html.j2 # HTML template with env variables
└── playbooks/
├── site.yml # Master playbook (full setup)
└── deploy.yml # App-only deployment
| Tool | Minimum Version | Install |
|---|---|---|
| Ansible | 2.14+ | brew install ansible |
| Docker Desktop | 4.0+ | docker.com |
| Docker Compose | v2+ | Included with Docker Desktop |
| Python | 3.8+ | brew install python |
Verify everything is installed:
ansible --version
docker --version
docker compose version
python3 --versiongit clone <your-repo-url>
cd ansible-multi-envssh-keygen -t ed25519 -f ./ansible_key -N ""
cp ansible_key.pub docker/authorized_keysdocker compose up -d --buildVerify all 6 containers are running:
docker compose psansible all -i inventories/dev/ -m pingExpected output:
dev-web-01 | SUCCESS => { "ping": "pong" }
dev-db-01 | SUCCESS => { "ping": "pong" }
# Dev
ansible-playbook playbooks/site.yml -i inventories/dev/
# Staging
ansible-playbook playbooks/site.yml -i inventories/staging/
# Prod
ansible-playbook playbooks/site.yml -i inventories/prod/curl http://localhost:8080 # Dev
curl http://localhost:8081 # Staging
curl http://localhost:8082 # ProdEach environment is isolated with its own variables defined in group_vars/all.yml.
| Variable | Dev | Staging | Prod |
|---|---|---|---|
env |
development | staging | production |
app_port |
8080 | 8080 | 80 |
nginx_worker_processes |
1 | 2 | 4 |
app_version |
latest | latest | 1.0.0 |
db_name |
app_dev | app_staging | app_prod |
Prod intentionally pins app_version to a specific release and runs more Nginx workers to reflect a real production configuration.
Runs on every server regardless of its function. Handles:
- APT cache update
- Installing base packages (
curl,git,htop,vim) - Setting timezone to
Etc/UTC - Creating the
/opt/appapplication directory
Runs on hosts in the [webservers] group. Handles:
- Installing Nginx
- Deploying
nginx.conffrom a Jinja2 template (injectsapp_portandnginx_worker_processes) - Ensuring Nginx is enabled and running
- Triggering a restart via handler when config changes
Runs on hosts in the [webservers] group after webserver. Handles:
- Ensuring
/opt/appexists with correct permissions - Deploying
index.htmlfrom a Jinja2 template (injectsenv,app_version,app_port)
Full provisioning playbook. Runs common on all servers, then webserver and app on web servers only.
ansible-playbook playbooks/site.yml -i inventories/dev/Lightweight playbook for redeploying the app without re-running the full setup. Useful when you only change application files.
ansible-playbook playbooks/deploy.yml -i inventories/prod/Each hosts.ini defines two groups: [webservers] and [databases]. The site.yml playbook targets them separately — common runs on all, while webserver and app run only on webservers.
Ansible uses Jinja2 to inject variables into config files at deploy time. For example, nginx.conf.j2 contains:
worker_processes {{ nginx_worker_processes }};
...
listen {{ app_port }};When deployed to dev, this becomes worker_processes 1 and listen 8080. In prod it becomes worker_processes 4 and listen 80.
Every task is idempotent — running the playbook multiple times produces the same result without unintended side effects. Ansible checks the current state before making changes and skips tasks that are already in the desired state.
The Restart Nginx handler in the webserver role only fires when the config file actually changes. This avoids unnecessary restarts on every playbook run.
- Create the inventory directory:
mkdir -p inventories/qa/group_vars-
Create
inventories/qa/hosts.iniwith your server IPs and ports. -
Create
inventories/qa/group_vars/all.ymlwith environment-specific variables. -
If using Docker, add the new containers to
docker-compose.ymlwith unique ports. -
Run the playbook:
ansible-playbook playbooks/site.yml -i inventories/qa/# Check containers are running
docker compose ps
# Test SSH manually
ssh -i ./ansible_key -p 2201 ubuntu@localhost
# Test Ansible ping
ansible all -i inventories/dev/ -m pingAdd DNS to Docker Engine settings (Settings → Docker Engine):
{
"dns": ["8.8.8.8", "8.8.4.4"]
}Then rebuild:
docker compose up -d --buildThe containers are ephemeral. If you recreate them with docker compose up -d, re-run the playbook:
ansible-playbook playbooks/site.yml -i inventories/dev/Check what's using the port:
lsof -i :8080Stop the conflicting process or change the port mapping in docker-compose.yml.