🚀 Deploying a Highly Scalable & Available Django Application on AWS with Terraform

Pravesh SudhaPravesh Sudha
10 min read

💡 Introduction

Welcome, Devs, to the world of cloud and automation! 🚀

If you’ve been following my blogs, you might remember that a few months ago I published an experimental project where I created an Employee Management Application using Django and migrated its database to Amazon RDS.

Recently, after clearing my AWS Solutions Architect Associate exam 🎉, I started reflecting on some of the exam questions around resilient and highly available architectures. That sparked an idea — why not take my earlier project and level it up to meet production-grade standards?

So, in this blog, we’ll upgrade the Employee Management App and deploy it on AWS using Terraform. Our end goal? A highly scalable and highly available architecture, following real-world market practices.

Without further ado, let’s dive in! 🏗️


💡 Pre-Requisites

Before we jump into building our scalable Django setup, let’s make sure you’ve got the essentials ready. ✅

  1. AWS Account – You’ll need an AWS account with an IAM user that has AdministratorAccess permissions (only for the sake of this project — in a production environment, you should always follow the principle of least privilege).

  2. AWS CLI Installed – Download and install the AWS CLI on your local machine.

  3. Configure IAM User – Run aws configure and provide the IAM user’s credentials to set up AWS CLI authentication.

  4. Terraform CLI Installed – Make sure Terraform is installed so we can define and deploy our infrastructure as code.

Once these items are checked off your list, you’re all set to start this awesome project. 🚀


💡 Youtube Demonstration


💡 Setting Up Terraform Remote Backend & Secrets

Since this project follows best practices for hosting a highly scalable and highly available application on AWS, we won’t be storing our Terraform state file locally. Instead, we’ll store it remotely to ensure team collaboration, backup, and resilience.

For this setup, I’m using Amazon S3 (with versioning enabled) to store the state file, and DynamoDB for state locking. Interestingly, while browsing the AWS Docs, I noticed that the S3 + DynamoDB combination is now deprecated in favor of a use_lockfile tag for S3 state locking. However, for some reason, the new method didn’t work on my machine — so I decided to stick with the tried-and-tested S3 + DynamoDB approach.

The complete Terraform code for this project is available on my GitHub:
https://github.com/Pravesh-Sudha/terra-projects

Navigate to the scripts directory:

cd terra-projects/two-tier-app/scripts

First, we’ll create the S3 bucket and DynamoDB table for our Terraform backend. I’ve created a config.sh script for this:

chmod u+x config.sh
./config.sh

This will create:

  • Bucket: pravesh-tf-two-tier-bucket

  • Table: pravesh-state-table

If you get an error that the bucket name is already taken, simply edit the name in the config.sh file and try again. Just remember to update the same name inside terra-config/backend.tf.

Next, since we’ll be deploying an RDS instance for our Django app, we need to securely store the database credentials. For this, we’ll use AWS Secrets Manager. In the scripts/commands.md file, you’ll find the exact commands, but here’s the process:

Create AWS Secrets for RDS Instance

aws secretsmanager create-secret \
  --name "employee-mgnt/rds-credentials" \
  --secret-string '{"username":"admin","password":"admin1234"}' \
  --region us-east-1

Create Policy for IAM User

aws iam create-policy --policy-name TerraformSecretsRead --policy-document '{
  "Version": "2012-10-17",
  "Statement": [
    {
      "Effect": "Allow",
      "Action": [
        "secretsmanager:GetSecretValue",
        "secretsmanager:DescribeSecret"
      ],
      "Resource": "arn:aws:secretsmanager:us-east-1:<account-id>:secret:employee-mgnt/rds-credentials-*"
    }
  ]
}'

Attach the Policy to IAM User

aws iam attach-user-policy --user-name <iam-user-name> --policy-arn arn:aws:iam::<account-id>:policy/TerraformSecretsRead

Replace <account-id> and <iam-user-name> with your own values before running the commands.

With this, our Terraform remote backend and secure database credentials are ready, and we can now move on to configuring our infrastructure.


💡Understanding the Terraform Configuration

Before we run any commands, let’s first understand what our Terraform code does and how it works under the hood. I’ll break down each .tf file so you can see exactly how all the pieces fit together.

provider.tf

provider "aws" {
  region = var.aws_region
}

We’re telling Terraform that our cloud provider is AWS and setting the region via the variable aws_region (default: us-east-1).

variables.tf

variable "aws_region" {
  default = "us-east-1"
}
variable "project_name" {
  default = "employee-mgnt"
}
variable "vpc_cidr" {
  default = "10.0.0.0/16"
}
variable "public_subnet_cidrs" {
  default = ["10.0.1.0/24", "10.0.2.0/24"]
}
variable "private_subnet_cidrs" {
  default = ["10.0.11.0/24", "10.0.12.0/24"]
}
variable "instance_type" {
  default = "t3.micro"
}

Here we define key parameters like VPC CIDR range, public/private subnets, EC2 instance type, and project name.

backend.tf

terraform {
  backend "s3" {
    bucket         = "pravesh-tf-two-tier-bucket"
    key            = "terraform/terrform.tfstate"
    region         = var.aws_region
    dynamodb_table = "pravesh-state-table"
    encrypt        = true
  }
}

This is our Terraform remote backend configuration. We’re storing the state file in S3 (with encryption) and using DynamoDB for state locking.

vpc.tf

We use the official terraform-aws-vpc module to create:

  • VPC with custom CIDR

  • Public & private subnets

  • NAT Gateway for outbound internet in private subnets

  • DNS hostnames & support enabled

module "vpc" {
  source = "terraform-aws-modules/vpc/aws"

  name = var.project_name
  cidr = var.vpc_cidr

  azs             = slice(data.aws_availability_zones.available.names, 0, 2)
  private_subnets = var.private_subnet_cidrs
  public_subnets  = var.public_subnet_cidrs

  enable_nat_gateway   = true
  single_nat_gateway   = false
  enable_dns_hostnames = true
  enable_dns_support   = true

  tags = {
    Project = var.project_name
  }
}

security_groups.tf

We define three security groups:

  1. Load Balancer SG – Allows HTTP (80) & HTTPS (443) from the internet.

  2. App SG – Allows traffic on port 8000 only from the ALB SG.

  3. RDS SG – Allows MySQL traffic (3306) only from the App SG.

This creates a secure, layered approach — only necessary services talk to each other.

# Load-Balancer Security Group
resource "aws_security_group" "alb_sg" {
  name        = "${var.project_name}-alb_sg"
  description = "Allow HTTP/HTTPS access from Internet"
  vpc_id      = module.vpc.vpc_id

  tags = {
    Name = "alb_sg"
  }
}

resource "aws_vpc_security_group_ingress_rule" "alb_sg_http_ipv4" {
  security_group_id = aws_security_group.alb_sg.id
  cidr_ipv4         = "0.0.0.0/0"
  from_port         = 80
  ip_protocol       = "tcp"
  to_port           = 80
}

resource "aws_vpc_security_group_ingress_rule" "alb_sg_https_ipv4" {
  security_group_id = aws_security_group.alb_sg.id
  cidr_ipv4         = "0.0.0.0/0"
  from_port         = 443
  ip_protocol       = "tcp"
  to_port           = 443
}

resource "aws_vpc_security_group_egress_rule" "alb_sg_egress" {
  security_group_id = aws_security_group.alb_sg.id
  cidr_ipv4         = "0.0.0.0/0"
  ip_protocol       = "-1"
}

# Frontend (Application) Security Group
resource "aws_security_group" "app_sg" {
  name        = "${var.project_name}-app_sg"
  description = "Allow port 8000 access from ALB only"
  vpc_id      = module.vpc.vpc_id

  tags = {
    Name = "app_sg"
  }
}

resource "aws_vpc_security_group_ingress_rule" "app_sg_from_alb" {
  security_group_id            = aws_security_group.app_sg.id
  referenced_security_group_id = aws_security_group.alb_sg.id
  from_port                    = 8000
  ip_protocol                  = "tcp"
  to_port                      = 8000
}

resource "aws_vpc_security_group_egress_rule" "app_sg_to_rds" {
  security_group_id = aws_security_group.app_sg.id
  cidr_ipv4         = "0.0.0.0/0"
  ip_protocol       = "-1"
}

# Database (Backend) Security Group
resource "aws_security_group" "rds_sg" {
  name        = "${var.project_name}-rds_sg"
  description = "Allow port 3306 access from App SG only"
  vpc_id      = module.vpc.vpc_id

  tags = {
    Name = "rds_sg"
  }
}

resource "aws_vpc_security_group_ingress_rule" "rds_sg_from_app" {
  security_group_id            = aws_security_group.rds_sg.id
  referenced_security_group_id = aws_security_group.app_sg.id
  from_port                    = 3306
  ip_protocol                  = "tcp"
  to_port                      = 3306
}

resource "aws_vpc_security_group_egress_rule" "rds_sg_egress" {
  security_group_id = aws_security_group.rds_sg.id
  cidr_ipv4         = "0.0.0.0/0"
  ip_protocol       = "-1"
}

data.tf

We fetch:

  • Available AZs in the region

  • Latest Ubuntu AMI

  • Database credentials from AWS Secrets Manager

We also define userdata for EC2 bootstrap configuration.

data "aws_availability_zones" "available" {
  state = "available"
}

data "aws_ami" "ubuntu" {
  most_recent = true
  owners      = ["amazon"]
  filter {
    name   = "name"
    values = ["ubuntu/images/hvm-ssd/ubuntu-jammy-22.04-amd64-server-*"]
  }
}

data "aws_secretsmanager_secret_version" "rds_credentials" {
  secret_id = "employee-mgnt/rds-credentials"  
}

locals {
  userdata = templatefile("${path.module}/userdata.tpl", {
    DB_NAME     = "employee_db"
    DB_USER     = jsondecode(data.aws_secretsmanager_secret_version.rds_credentials.secret_string)["username"]
    DB_PASSWORD = jsondecode(data.aws_secretsmanager_secret_version.rds_credentials.secret_string)["password"]
    DB_PORT     = "3306"
    DB_HOST = aws_db_instance.rds_instance.endpoint
  })
}

alb.tf

Creates:

  • Application Load Balancer in public subnets

  • Target Group (port 8000, health check at /health)

  • Listener on port 80 forwarding requests to the target group

resource "aws_lb" "app_alb" {
  name               = "${var.project_name}-alb"
  internal           = false
  load_balancer_type = "application"
  security_groups    = [aws_security_group.alb_sg.id]
  subnets            = module.vpc.public_subnets
}

resource "aws_lb_target_group" "app_tg" {
  name     = "${var.project_name}-tg"
  port     = 8000
  protocol = "HTTP"
  vpc_id   = module.vpc.vpc_id
  health_check {
    path                = "/health"
    protocol            = "HTTP"
    matcher             = "200"
    healthy_threshold   = 2
    unhealthy_threshold = 3
    interval            = 30
    timeout             = 5
  }
}

resource "aws_lb_listener" "app_listener" {
  load_balancer_arn = aws_lb.app_alb.arn
  port              = "80"
  protocol          = "HTTP"

  default_action {
    type             = "forward"
    target_group_arn = aws_lb_target_group.app_tg.arn
  }
}

iam.tf

Sets up:

  • IAM Role for EC2

  • SSM Policy attachment (for remote management)

  • Instance Profile for EC2 instances

resource "aws_iam_role" "ec2-role" {
  name = "ec2-role"

  assume_role_policy = jsonencode({
    Version = "2012-10-17"
    Statement = [
      {
        Action = "sts:AssumeRole"
        Effect = "Allow"
        Principal = {
          Service = "ec2.amazonaws.com"
        }
      },
    ]
  })
}

resource "aws_iam_role_policy_attachment" "ssm_policy" {
  role       = aws_iam_role.ec2-role.name
  policy_arn = "arn:aws:iam::aws:policy/AmazonSSMManagedInstanceCore"
}

resource "aws_iam_instance_profile" "instance_profile" {
  name = "${var.project_name}-instance_profile"
  role = aws_iam_role.ec2-role.name
}

asg_launch_template.tf

Defines:

  • Launch Template with Ubuntu AMI, instance type, security group, and bootstrap script

  • Auto Scaling Group across private subnets with ALB health checks

resource "aws_launch_template" "lt" {
  name_prefix   = "${var.project_name}-lt-"
  image_id      = data.aws_ami.ubuntu.id
  instance_type = var.instance_type

  iam_instance_profile {
    name = aws_iam_instance_profile.instance_profile.name
  }

  network_interfaces {
    security_groups             = [aws_security_group.app_sg.id]
    associate_public_ip_address = false
  }

  user_data = base64encode(local.userdata)

}

resource "aws_autoscaling_group" "app-ag" {
  name                = "${var.project_name}-ag"
  max_size            = 2
  min_size            = 1
  desired_capacity    = 1
  vpc_zone_identifier = module.vpc.private_subnets

  launch_template {
    id      = aws_launch_template.lt.id
    version = "$Latest"
  }
  target_group_arns = [aws_lb_target_group.app_tg.arn]

  # Tie ASG health to ALB checks
  health_check_type         = "ELB"
  health_check_grace_period = 120

  tag {
    key                 = "Name"
    value               = "${var.project_name}-app"
    propagate_at_launch = true
  }
}

userdata.tpl

Bootstrap script that:

  • Installs dependencies

  • Fetches DB credentials from Secrets Manager

  • Creates the database if it doesn’t exist

  • Clones the Employee Management Django app from GitHub

  • Runs migrations

  • Starts the app with Gunicorn on port 8000

#!/bin/bash

set -e
export DEBIAN_FRONTEND=noninteractive

# basic deps
apt-get update -y
apt-get install -y git python3 python3-pip python3-venv mysql-client libmysqlclient-dev build-essential pkg-config

# export DB env variables (inherited by processes started below)
export DB_NAME="${DB_NAME}"
export DB_USER="${DB_USER}"
export DB_PASSWORD="${DB_PASSWORD}"
export DB_HOST="${DB_HOST}"
export DB_PORT="3306"

echo "DB_NAME=$DB_NAME" >> /home/ubuntu/userdata.log
echo "DB_USER=$DB_USER" >> /home/ubuntu/userdata.log
echo "DB_PASSWORD=$DB_PASSWORD" >> /home/ubuntu/userdata.log
echo "DB_HOST=$DB_HOST" >> /home/ubuntu/userdata.log
echo "DB_PORT=$DB_PORT" >> /home/ubuntu/userdata.log

# Create database if it doesn't exist
mysql -h "$(echo ${DB_HOST} | cut -d : -f1)" -u "${DB_USER}" -p"${DB_PASSWORD}" -P "${DB_PORT}" -e "CREATE DATABASE IF NOT EXISTS ${DB_NAME};" 2>> /home/ubuntu/userdata.log

# Remove existing /home/ubuntu/app directory if it exists
if [ -d "/home/ubuntu/app" ]; then
  rm -rf /home/ubuntu/app
fi

# Clone & install app
git clone https://github.com/Pravesh-Sudha/employee_management.git /home/ubuntu/app 2>> /home/ubuntu/userdata.log
cd /home/ubuntu/app
python3 -m venv venv
source venv/bin/activate
pip install -r requirements.txt 2>> /home/ubuntu/userdata.log

chown -R ubuntu:ubuntu /home/ubuntu/app


# Small randomized sleep to reduce concurrent migrations
sleep $((RANDOM % 10))

# Retry migrations (5 attempts)
attempt=0
until [ $attempt -ge 5 ]
do
  attempt=$((attempt+1))
  echo "Running migrations (attempt $attempt)..." tee -a /home/ubuntu/userdata.log
  python manage.py migrate --noinput >> /home/ubuntu/migrate.log 2>&1 && break
  echo "Migrate failed, retrying in 5s..." | tee -a /home/ubuntu/userdata.log
  sleep 5
done

# start gunicorn
nohup /home/ubuntu/app/venv/bin/gunicorn --workers 2 --timeout 60 --access-logfile /home/ubuntu/gunicorn-access.log --error-logfile /home/ubuntu/gunicorn-error.log employee_management.wsgi:application --bind 0.0.0.0:8000 &

# Done
echo "user-data finished"

outputs.tf

Displays:

  • ALB DNS Name → The public URL of our app

  • RDS Endpoint → The database connection string

output "alb_dns_name" {
  description = "DNS name of the Application Load Balancer"
  value       = aws_lb.app_alb.dns_name
}


output "rds_endpoint" {
  description = "Endpoint of the RDS instance"
  value       = aws_db_instance.rds_instance.endpoint
}

Running the Terraform Code

Now that we understand the structure, let’s run it:

cd terra-projects/two-tier-app/terra-config
terraform init
terraform plan
terraform apply --auto-approve

Provisioning will take 10–15 minutes, so grab a coffee ☕ while AWS spins up your infrastructure.

Once the architecture is ready, open the alb_dns_name in your web browser — you’ll see the Django Employee Management application up and running.

You can now:

  • Add employee information and save it.

  • Refresh the browser to confirm the data persists, proving it’s connected to the RDS MySQL database.

  • Check the Target Groups in the Load Balancer console to see your healthy instances in action.

With this setup, we’ve successfully deployed a highly available, scalable, and secure Django Employee Management application on AWS, with credentials securely stored in AWS Secrets Manager.

You can check the application logs by SSM session

Clean-Up to Avoid Charges:

After experimenting, run the following command to delete all AWS resources:

terraform destroy --auto-approve

To remove the S3 bucket and DynamoDB table used for Terraform state, navigate to the scripts directory and run:

chmod u+x delete.sh
./delete.sh

This ensures all infrastructure is cleaned up, preventing unnecessary costs.


💡 Conclusion

In this project, we successfully deployed a Django Employee Management Application on AWS using a highly available, scalable, and secure architecture. By integrating ALB, EC2 Auto Scaling, RDS MySQL, Secrets Manager, and an S3 + DynamoDB backend for Terraform state, we created a production-ready setup that can handle growth while keeping credentials safe.

We also explored how the application persists data in RDS, verified healthy instances via Target Groups, and ensured cost optimization by cleaning up resources with terraform destroy and custom scripts. This hands-on project not only strengthens your AWS and Terraform skills but also gives you a blueprint for hosting real-world applications in the cloud.

If you found this guide helpful, don’t forget to connect with me and explore more of my work:

0
Subscribe to my newsletter

Read articles from Pravesh Sudha directly inside your inbox. Subscribe to the newsletter, and don't miss out.

Written by

Pravesh Sudha
Pravesh Sudha

Bridging critical thinking and innovation, from philosophy to DevOps.