Skip to content

srniraula/3tier-todo-application-on-aws

Folders and files

NameName
Last commit message
Last commit date

Latest commit

 

History

2 Commits
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

3-Tier Todo Application Deployment with Ansible & Docker on AWS

Overview

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).


Workflow

1. Networking (AWS VPC Setup)

  • 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
  • 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:8000 mapping 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

2. Building & Pushing Docker Images to ECR

Frontend

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 nginx as the base image
  • Copies static files into /usr/share/nginx/html (Nginx's default static file directory)
  • Copies a custom nginx.conf to /etc/nginx/conf.d/default.conf, overriding the default config that exists in the official Nginx Docker image

Backend

The Dockerfile for the backend:

  • Uses python as the base image
  • Copies requirements.txt and installs dependencies
  • Copies main.py
  • Exposes port 8000
  • Sets the startup command to launch the FastAPI server

Pushing to ECR

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-1

Step 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.com

This 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:latest

3. Ansible Configuration

Ansible provisions and configures all 3 EC2 instances. The two main files are inventory.ini and playbook.yml.

How Ansible Works (Under the Hood)

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.sh

Once 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 python3

But 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.

Securing Secrets with Ansible Vault

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 exit

Reference 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-pass

Edit secrets later:

ansible-vault edit secrets.yml

Change vault password:

ansible-vault rekey secrets.yml

inventory.ini — File Explanation

This 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 playbook hosts: 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

playbook.yml — Structure & Explanation

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: value

Task structure:

- name: Install required system packages
  apt:                      # Ansible module
    pkg:                    # module parameter
      - curl
      - ca-certificates
    state: present          # idempotent — only installs if not already present

Variable interpolation with Jinja2:

MYSQL_ROOT_PASSWORD: "{{ db_root_password }}"   # replaced at runtime from secrets.yml

Full 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: yes on docker_container tasks forces Ansible to check ECR for a newer image every run — useful for CI/CD redeployments
  • no_log: true on the ECR auth shell task prevents the temporary token from appearing in logs
  • docker_host: unix:///var/run/docker.sock explicitly 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's docker_container module compiles to a Python script that calls import docker — without the SDK, this import fails
  • requests==2.31.0 is pinned because requests >= 2.32 broke the http+docker:// custom URL scheme the Docker SDK uses to communicate with the Docker daemon socket

Architecture Diagram

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) │
└─────────────────────────────┘

Some screenshots showing configurations and outputs

AWS VPC resource map showing subnets, route tables, and network connections Overall VPC layout with the frontend, backend, and database tiers connected through route tables and network links. AWS subnets list showing frontend, backend, and database subnets Subnet configuration for the three tiers, separated into public and private network zones. AWS route tables list showing routes for frontend, backend, and database Route tables used to direct traffic between the public internet, NAT gateway, and private subnets. AWS EC2 instances list showing mydb, backend, and webserver Running EC2 instances for the database, backend, and frontend layers. Browser view of the deployed My Todos application Final deployed Todo app running in the browser through the frontend reverse proxy.

About

Tried to deploy 3 tier todo application in aws with ansible as configuration management tool.

Resources

Stars

Watchers

Forks

Releases

Packages

Contributors

Languages