This project provisions and configures 3 AWS EC2 instances using Ansible to run a 3-tier Todo application (Frontend, Backend, Database) using Docker containers. Images are stored in AWS Elastic Container Registry (ECR).
- Created a VPC
- Created an Internet Gateway and attached it to the VPC
- Created 3 subnets — one public, two private — to isolate the 3 tiers
- Created 3 route tables:
- Public subnet route table: routes
0.0.0.0/0→ Internet Gateway,10.0.1.0/24→ local - Private subnet 1 & 2 route tables: routes
0.0.0.0/0→ NAT Gateway,10.0.1.0/24→ local
- Public subnet route table: routes
- Created a NAT Gateway in the public subnet (with an Elastic IP) so private subnet instances can reach the internet for downloading packages. The public subnet's route table already routes its outbound traffic through the Internet Gateway.
- Created 3 Security Groups:
- Frontend (public subnet): allows HTTP (80) and HTTPS (443) from anywhere; SSH (22) from My IP only
- Backend (private subnet 1): allows SSH (22) and TCP on port 8000 from the frontend security group only — port 8000 is where FastAPI listens, matching the
8000:8000mapping in the Dockerfile - Database (private subnet 2): allows SSH (22) from the frontend security group and MySQL (3306) from the backend security group only
- Launched 3 EC2 instances (frontend, backend, database) in their respective subnets with appropriate security groups attached
The frontend consists of static files (index.html, index.js, style.css) that run in the user's browser. Since the browser cannot directly reach the backend in the private subnet, Nginx acts as a reverse proxy:
- Nginx serves the static files directly
- When JavaScript makes API calls, Nginx receives them and forwards them into the private network to reach FastAPI, then returns the response to the browser
The Dockerfile for the frontend:
- Uses
nginxas the base image - Copies static files into
/usr/share/nginx/html(Nginx's default static file directory) - Copies a custom
nginx.confto/etc/nginx/conf.d/default.conf, overriding the default config that exists in the official Nginx Docker image
The Dockerfile for the backend:
- Uses
pythonas the base image - Copies
requirements.txtand installs dependencies - Copies
main.py - Exposes port
8000 - Sets the startup command to launch the FastAPI server
Step 1 — Configure AWS credentials:
aws configure
# Enter: Access Key ID, Secret Access Key, region (eu-north-1), output format (json)Step 2 — Create ECR repositories:
aws ecr create-repository --repository-name my-frontend-app --region eu-north-1
aws ecr create-repository --repository-name my-backend-app --region eu-north-1Step 3 — Authenticate local Docker client to ECR:
aws ecr get-login-password --region eu-north-1 | docker login --username AWS --password-stdin <AWS_ACCOUNT_ID>.dkr.ecr.eu-north-1.amazonaws.comThis requests a temporary auth token from AWS (valid for 12 hours) and uses it to log Docker into ECR. --username AWS is hardcoded and compulsory — it's a fixed placeholder ECR expects by convention. ECR only validates the token, not the username.
Step 4 — Build and tag images:
# Frontend
cd /path/to/frontend
docker build -t <AWS_ACCOUNT_ID>.dkr.ecr.eu-north-1.amazonaws.com/my-frontend-app:latest .
# Backend
cd /path/to/backend
docker build -t <AWS_ACCOUNT_ID>.dkr.ecr.eu-north-1.amazonaws.com/my-backend-app:latest .Step 5 — Push to ECR:
docker push <AWS_ACCOUNT_ID>.dkr.ecr.eu-north-1.amazonaws.com/my-frontend-app:latest
docker push <AWS_ACCOUNT_ID>.dkr.ecr.eu-north-1.amazonaws.com/my-backend-app:latestAnsible provisions and configures all 3 EC2 instances. The two main files are inventory.ini and playbook.yml.
Ansible reads the playbook, identifies the target hosts from the inventory, SSHes into each host, and executes the tasks. In more detail:
- Ansible converts each task into a small Python script (module)
- Sends that script to the target host via SCP over SSH into
/tmp/ - Python interpreter on the target host executes the script
- The script returns a JSON result back to the control node
- Ansible deletes the temp script and moves to the next task
Bootstrapping a fresh server (no Python installed yet): On a brand new server, only the OS, package manager, and shell exist — no Python interpreter. Ansible handles this with two special modules that bypass Python entirely and run pure shell commands directly over SSH:
# raw module - for simple shell commands
- name: Install Python first
raw: apt-get install -y python3
# script module - for running a local shell script on the target
- name: Run setup script
script: setup.shOnce Python is installed, all other Ansible modules work normally.
Also note: gather_facts (which Ansible runs automatically at the start of each play to collect system info) also requires Python — so disable it before Python is installed:
- hosts: all
gather_facts: false # disable until Python exists
tasks:
- raw: apt-get install -y python3But this is not required in aws because official AWS Ubuntu AMIs come with Python 3 pre-installed out of the box, our playbook worked flawlessly without needing raw hooks.
Environment variables (DB passwords, usernames, etc.) are stored in secrets.yml and encrypted with Ansible Vault. Even if pushed to a public repo, the file contents are unreadable without the password.
Create and encrypt the secrets file:
ansible-vault create secrets.yml
# Enter a vault password when prompted
# An editor opens — write your variables:
# db_root_password: "root@123"
# db_name: "todo"
# db_user: "appuser"
# Save and exitReference secrets in the playbook:
vars_files:
- secrets.yml
env:
MYSQL_ROOT_PASSWORD: "{{ db_root_password }}"
MYSQL_DATABASE: "{{ db_name }}"Run the playbook with vault password:
ansible-playbook -i inventory.ini playbook.yml --ask-vault-passEdit secrets later:
ansible-vault edit secrets.ymlChange vault password:
ansible-vault rekey secrets.ymlThis file tells Ansible which hosts to connect to and how. See inventory.ini in this repository for the full file. Key concepts:
- Groups
[groupname]— logical collections of hosts, referenced in playbookhosts:field - Host entry — each line defines an alias, IP, SSH user, and key path
- Parent group
[groupname:children]— a meta group containing other groups; vars applied here propagate to all children - Group vars
[groupname:vars]— variables applied to every host in the group - ProxyCommand — used for private subnet hosts that aren't directly reachable; SSH tunnels through the frontend (bastion) host to reach backend and database nodes
A playbook is a list of plays. Each play targets a host group and runs a list of tasks.
The 4 plays and what they do:
| Play | Hosts | What it does |
|---|---|---|
| Play 1 | all |
Install Docker on all 3 nodes |
| Play 2 | database |
Pull and launch MySQL container |
| Play 3 | backend |
Authenticate to ECR, pull and launch FastAPI container |
| Play 4 | frontend |
Authenticate to ECR, pull and launch Nginx/Frontend container |
Play structure:
- name: Phase 1 - Install Docker on All Nodes
hosts: all # target group from inventory.ini
become: yes # run with sudo
vars_files: # load encrypted variable files
- secrets.yml
tasks:
- name: Task description
module_name:
parameter: valueTask structure:
- name: Install required system packages
apt: # Ansible module
pkg: # module parameter
- curl
- ca-certificates
state: present # idempotent — only installs if not already presentVariable interpolation with Jinja2:
MYSQL_ROOT_PASSWORD: "{{ db_root_password }}" # replaced at runtime from secrets.ymlFull playbook skeleton:
playbook.yml
│
├── Play 1 (hosts: all)
│ ├── Update apt cache
│ ├── Install system packages
│ ├── Create keyrings directory
│ ├── Download Docker GPG key
│ ├── Add Docker repository
│ ├── Install Docker CE
│ ├── Start and enable Docker service
│ ├── Install Python Docker SDK (pip install docker)
│ └── Downgrade requests library to 2.31.0 (fix http+docker bug)
│
├── Play 2 (hosts: database)
│ ├── Create persistent MySQL data directory
│ └── Pull and launch MySQL container
│
├── Play 3 (hosts: backend)
│ ├── Install AWS CLI
│ ├── Authenticate Docker to ECR
│ └── Pull and launch FastAPI container
│
└── Play 4 (hosts: frontend)
├── Install AWS CLI
├── Authenticate Docker to ECR
└── Pull and launch Nginx/Frontend container
Notable task-level details:
pull: yesondocker_containertasks forces Ansible to check ECR for a newer image every run — useful for CI/CD redeploymentsno_log: trueon the ECR auth shell task prevents the temporary token from appearing in logsdocker_host: unix:///var/run/docker.sockexplicitly tells the Docker SDK to talk to the local Docker daemon via its Unix socket (this is the default but written explicitly for clarity)- The Python Docker SDK (
pip install docker) is required on each target host because Ansible'sdocker_containermodule compiles to a Python script that callsimport docker— without the SDK, this import fails requests==2.31.0is pinned becauserequests >= 2.32broke thehttp+docker://custom URL scheme the Docker SDK uses to communicate with the Docker daemon socket
Internet
│
▼
Internet Gateway
│
▼
Public Subnet
┌─────────────────────────────┐
│ Frontend EC2 (Nginx) │ ← serves static files + reverse proxy
│ NAT Gateway │ ← outbound internet for private subnets
└─────────────────────────────┘
│
▼ (private network)
Private Subnet 1
┌─────────────────────────────┐
│ Backend EC2 (FastAPI :8000)│
└─────────────────────────────┘
│
▼
Private Subnet 2
┌─────────────────────────────┐
│ Database EC2 (MySQL :3306) │
└─────────────────────────────┘
Overall VPC layout with the frontend, backend, and database tiers connected through route tables and network links.
Subnet configuration for the three tiers, separated into public and private network zones.
Route tables used to direct traffic between the public internet, NAT gateway, and private subnets.
Running EC2 instances for the database, backend, and frontend layers.
Final deployed Todo app running in the browser through the frontend reverse proxy.