Infrastructure provisioned from code. Application deployed and verified automatically.
This project deploys the Mini Finance static site to an Azure VM using Terraform for infrastructure provisioning and Ansible for configuration, deployment, and verification — with clean separation of concerns between the two tools.
| Capability | Implementation |
|---|---|
| Infrastructure as Code | Terraform provisions all Azure resources — no manual console interaction |
| Multi-play Ansible automation | Clean separation between install, deploy, and verify plays |
| Secure by default | Key-based SSH only; NSG locked to inbound ports 22 and 80 |
| Verified delivery | Play 3 asserts status_code: 200 — the pipeline does not complete successfully unless the site is reachable |
| Real-world debugging | Deployment completed without errors but returned 403 Forbidden — root cause identified, fixed, and revalidated |
Terraform provisions:
Azure Resource Group (rg-mini-finance)
└── Virtual Network 10.0.0.0/16
└── Subnet 10.0.1.0/24
├── NSG inbound: TCP 22 (SSH), TCP 80 (HTTP)
├── NIC + Static Public IP
└── Ubuntu 22.04 VM (Standard_B2ats_v2)
└── Key-based SSH only (password auth disabled)
Ansible configures and deploys:
Play 1 — Install apt update → install nginx + git → start and enable nginx
Play 2 — Deploy clone repo → sync to /var/www/html/ → set www-data ownership → reload nginx
Play 3 — Verify uri module → GET http://<public_ip> → assert status_code == 200
- Azure CLI — authenticated (
az login) - Terraform >= 1.5.0
- Ansible
- SSH key pair at
~/.ssh/id_rsa_azure(private) and~/.ssh/id_rsa_azure.pub(public)
cd terraform/
cp terraform.tfvars.example terraform.tfvars
# Edit terraform.tfvars if you need a different region, VM size, or admin username
terraform init
terraform plan
terraform applyterraform apply outputs the public_ip on completion. Note it — you need it in the next step.
Edit ansible/inventory.ini and replace <public_ip> with the value from step 1:
[web]
<public_ip>
[web:vars]
ansible_user=azureuser
ansible_ssh_private_key_file=~/.ssh/id_rsa_azuressh azureuser@<public_ip> "hostname"Must connect without a password prompt before proceeding.
cd ansible/
ansible-playbook -i inventory.ini site.ymlA successful run ends with:
TASK [Assert HTTP 200 was returned]
ok: [localhost] => {
"msg": "Mini Finance site is reachable and returned HTTP 200"
}
Navigate to http://<public_ip> — the Mini Finance site loads.
The initial deployment completed without errors. The Ansible verify play returned 403 Forbidden, not 200.
Root cause: File ownership. Content had been synced to /var/www/html/ but Nginx could not serve it because the files were not owned by www-data. Play 2 sets owner: www-data and group: www-data on the copy task, and applies recurse: true via the file task — but these were not applied correctly on the first run.
Fix: Corrected the ownership configuration, redeployed, revalidated. The verify play returned HTTP 200.
Why this matters: The verify play (Play 3) exists precisely for this class of problem. "It ran without errors" is not the same as "it works." Automated verification makes the difference observable and actionable without manual checking.
mini-finance/
├── .github/
│ └── workflows/
│ └── ci.yml # Terraform fmt check + Ansible syntax check on push
├── ansible/
│ ├── inventory.ini # Host inventory — replace <public_ip> after terraform apply
│ └── site.yml # Multi-play playbook: install → deploy → verify
├── terraform/
│ ├── .terraform.lock.hcl # Provider dependency lock file
│ ├── main.tf # All Azure resources
│ ├── outputs.tf # Public IP output
│ ├── providers.tf # AzureRM provider and version constraints
│ ├── terraform.tfvars.example
│ └── variables.tf # Input variable definitions with defaults
├── .gitignore
└── README.md
- Terraform — Infrastructure as Code, Azure provider
~> 3.100 - Ansible — Configuration management and deployment automation
- Azure — Resource Group, Virtual Network, Subnet, NSG, Public IP, NIC, Ubuntu 22.04 VM
- Nginx — Web server
- GitHub Actions — CI: Terraform format check and Ansible syntax check on every push