A Flask task-management (Todo) app deployed to Azure Container Apps behind an Application Gateway, provisioned end-to-end with Terraform and shipped through Azure DevOps CI/CD. Staging and production run as separate Terraform root configurations in isolated Azure subscriptions, both in UK South.
- Application — app/: a Flask REST API with a small server-rendered UI. Task state is held in memory (a module-level dict), so it resets on restart and is not shared across replicas; there is no database.
- Infrastructure — terraform/: composed of
network,container_app,dns, andapp_gatewaymodules under terraform/modules/. The TLS certificate is issued into Key Vault by theapp_gatewaymodule (keyvault.tf). - Environments —
stagingandproductionare separate root configs with their own backend, provider, and lock file, under terraform/environments/. Per-environment settings (subscription, resource group, image, hostname, task sizing) live in each environment'sterraform.tfvars.
Public DNS → Application Gateway public IP (in snet-appgw) → HTTPS listener terminating TLS with a Key Vault certificate → re-encrypted to the Container App's FQDN, which a private DNS zone resolves to the Container Apps environment's private static IP in snet-aca → Flask container on port 3000. Port 80 permanently redirects to HTTPS. The environment uses an internal load balancer, so nothing behind the gateway is publicly reachable.
Routes: GET / (HTML UI), GET /health (App Gateway probe and CD smoke test), and CRUD under /tasks.
cd app
pip install -r requirements-dev.txt
python app.py # serves on http://localhost:3000Then open http://localhost:3000, or exercise the API:
curl http://localhost:3000/health
curl -X POST http://localhost:3000/tasks -H 'Content-Type: application/json' -d '{"title":"Write docs"}'
curl http://localhost:3000/taskspytest # run all tests
pytest --cov=app --cov-report=term # with coverage (as CI runs it)
ruff format --check . # formatting gate
ruff check . # lint gate
pip-audit -r requirements.txt # dependency auditapp/Dockerfile is a multi-stage build producing a hardened distroless image running as non-root UID 65532, exposing port 3000. There is no shell in the final image.
docker build -t task-management ./app
docker run -p 3000:3000 task-managementRun from inside an environment directory — each has its own backend and state, so there is no workspace to select.
cd terraform/environments/staging # or: terraform/environments/production
terraform init
terraform plan
terraform apply
terraform fmt -check # formatting gate used by the plan pipelineState is stored in an Azure Storage account backend (providers.tf); terraform/bootstrap-backend.sh creates that storage account once, before the first init. To change sizing, hostnames, or images for an environment, edit that environment's terraform.tfvars. To change infrastructure shape, edit the module and both environment main.tf files.
app_gateway_public_ip, container_app_fqdn.
All pipelines live in pipelines/ and authenticate to Azure through service connections (azure-service-connection for staging, prod-service-connection for production).
| Pipeline | Trigger | What it does |
|---|---|---|
| ci.yaml | Push on main for app/** |
Ruff, pytest + coverage, pip-audit, gitleaks, Docker build + Trivy scan (fails on CRITICAL); pushes :latest and the commit SHA to the staging and production ACRs on non-PR pushes to main |
| cd.yaml | Pipeline resource — after ci succeeds on main |
Resolves the image by digest, deploys to staging → smoke-tests https://staging.tflow.co.uk/health → deploys to production |
| terraform-plan.yaml | PR via Branch Policy → Build Validation | Format check, validate, and plan for both environments; posts rendered plans as PR comments |
| terraform-apply.yaml | Push on main for terraform/** |
Applies staging, then production |
| terraform-destroy.yaml | Manual dispatch | Destroys a chosen environment |
Deploys and applies are gated by Azure DevOps Environments — add an approval check on the production environment (Pipelines → Environments → production) to enforce manual approval.
The CD deployment targets (
stagingContainerApp,stagingResourceGroup,containerName) are hard-coded in cd.yaml, not derived from Terraform outputs, so they must match the names Terraform actually creates (e.g.ca-taskflow-staging,staging-taskflow).
app/ Flask application, tests, Dockerfile
templates/, static/ Server-rendered UI
tests/ pytest suite
terraform/
environments/ Per-environment root configs (staging, production)
modules/ network, container_app, dns, app_gateway
bootstrap-backend.sh One-time remote-state setup
pipelines/ Azure DevOps CI, CD, and Terraform plan/apply/destroy
images/ Architecture diagram
