Deploy Flask Backend on AWS EC2 with Terraform

  • Post comments:0 Comments

When building cloud-native applications, it’s crucial to have a reliable and reproducible backend infrastructure. In this guide, we’ll walk through how to deploy a Flask backend on AWS EC2 with Terraform, setting up a Python Flask application on a single EC2 instance, fully provisioned using Terraform for consistent and maintainable infrastructure management.

By the end of this post, you’ll know how to set up a minimal yet production-ready backend environment with:

  • A Flask app served via Gunicorn and Nginx.
  • Secure and isolated networking using VPC, subnets, security groups.
  • Logging to CloudWatch for observability.

Let’s dive in!


✅ Why Use Terraform for Backend Deployment?

Infrastructure as Code (IaC) lets you define, deploy, and manage your cloud infrastructure through code. Terraform enables repeatable, version-controlled deployments and minimizes human error when setting up complex resources like EC2 instances, IAM roles, and networking.

In this tutorial, everything is controlled through Terraform:

  • Compute (EC2)
  • Network (VPC, Subnets, Security Groups)
  • IAM roles and CloudWatch logging

⚙️ Architecture Overview

  • EC2 Instance running Flask (with Gunicorn + Nginx)
    • We’ll deploy a lightweight Python Flask application on an Amazon EC2 instance. The Flask app will be served by Gunicorn, a Python WSGI HTTP server, ensuring efficient request handling, and Nginx will act as a reverse proxy, managing incoming traffic and forwarding it to Gunicorn. Nginx also allows us to handle static assets, enforce HTTPS (in future steps), and apply other web server optimizations.
  • VPC (Virtual Private Cloud) for Isolation
    • All resources will be placed inside a dedicated VPC, providing a logically isolated network environment. This allows us to control and restrict network traffic at a granular level, ensuring better security, control, and scalability. The VPC creates a safe boundary around our backend service, separating it from other AWS resources and the public internet.
  • Public & Private Subnets
    • We’ll define two public subnets for resources like the EC2 instance that require internet access (e.g., to serve HTTP requests and allow SSH for management).
    • Additionally, we’ll create two private subnets reserved for internal components we may add later — such as databases, caches, or internal microservices — that should never be exposed to the internet. This structure prepares us for scaling and adding more secure components in the future.
  • Security Groups for Controlled Access
    • To secure our backend, we’ll create a Security Group (virtual firewall) that explicitly allows:
    • Ingress traffic on port 80 (HTTP) so users can access the Flask app.
    • Ingress traffic on port 22 (SSH) for secure remote management of the instance.
    • Egress traffic to allow outbound communication from the EC2 instance to the internet (e.g., for software updates, package installations).
    • This way, only allowed traffic will reach our EC2 instance, and we can easily adjust these rules via Terraform as needed.
  • IAM Role for CloudWatch Logging
    • To monitor and debug our application, we’ll attach an IAM Role to the EC2 instance with a policy that grants permissions to write logs to AWS CloudWatch. This integration allows us to capture and analyze logs from Nginx, Gunicorn, and Flask, providing visibility into system and application behavior and helping with performance tuning and issue resolution.

🔑 Key Components

  • VPC and Networking

The VPC contains two public and two private subnets, along with essential components like an Internet Gateway, Route Table, and Security Groups.

resource "aws_vpc" "main_vpc" {
  cidr_block       = "10.0.0.0/16"
  instance_tenancy = "default"

  tags = {
    Name = "MainVPC"
  }
}

# PUBLIC SUBNETS

resource "aws_subnet" "public_subnet_1" {
  vpc_id                  = aws_vpc.main_vpc.id
  cidr_block              = "10.0.1.0/24"
  availability_zone       = "${var.aws_region}a"
  map_public_ip_on_launch = true

  tags = {
    Name = "PublicSubnet1"
  }
}

resource "aws_subnet" "public_subnet_2" {
  vpc_id                  = aws_vpc.main_vpc.id
  cidr_block              = "10.0.2.0/24"
  availability_zone       = "${var.aws_region}b"
  map_public_ip_on_launch = true

  tags = {
    Name = "PublicSubnet2"
  }
}

# PRIVATE SUBNETS

resource "aws_subnet" "private_subnet_1" {
  vpc_id                  = aws_vpc.main_vpc.id
  cidr_block              = "10.0.101.0/24"
  availability_zone       = "${var.aws_region}a"
  map_public_ip_on_launch = true

  tags = {
    Name = "PrivateSubnet1"
  }
}

resource "aws_subnet" "private_subnet_2" {
  vpc_id                  = aws_vpc.main_vpc.id
  cidr_block              = "10.0.102.0/24"
  availability_zone       = "${var.aws_region}b"
  map_public_ip_on_launch = true

  tags = {
    Name = "PrivateSubnet2"
  }
}


// IGW
resource "aws_internet_gateway" "igw" {
  vpc_id = aws_vpc.main_vpc.id

  tags = {
    Name = "MainIGW"
  }
}

// RT
resource "aws_route_table" "public_rt" {
  vpc_id = aws_vpc.main_vpc.id

  route {
    cidr_block = "0.0.0.0/0"
    gateway_id = aws_internet_gateway.igw.id
  }

  tags = {
    Name = "PublicRouteTable"
  }
}

# Associate Route Table with Public Subnet
resource "aws_route_table_association" "public_subnet_1_assoc" {
  subnet_id      = aws_subnet.public_subnet_1.id
  route_table_id = aws_route_table.public_rt.id
}

resource "aws_route_table_association" "public_subnet_2_assoc" {
  subnet_id      = aws_subnet.public_subnet_2.id
  route_table_id = aws_route_table.public_rt.id
}
  • Security Groups

The Security Group allows inbound HTTP (80) and SSH (22), and outbound traffic.

resource "aws_security_group" "flask_sg" {
  name        = "flask-sg"
  description = "Allow inbound traffic on port 80"
  vpc_id      = aws_vpc.main_vpc.id

  tags = {
    Name = "FlaskSG"
  }
}

resource "aws_vpc_security_group_ingress_rule" "sg_ingress" {
  security_group_id = aws_security_group.flask_sg.id

  from_port   = 80
  to_port     = 80
  ip_protocol = "tcp"
  cidr_ipv4   = "0.0.0.0/0"
  description = "FROM THE INTERNET"
}

resource "aws_vpc_security_group_ingress_rule" "sg_ingress_ssh" {
  security_group_id = aws_security_group.flask_sg.id

  from_port   = 22
  to_port     = 22
  ip_protocol = "tcp"
  cidr_ipv4   = "0.0.0.0/0"
  description = "SSH Access"
}

resource "aws_vpc_security_group_egress_rule" "sg_egress" {
  security_group_id = aws_security_group.flask_sg.id

  ip_protocol = "-1"
  cidr_ipv4   = "0.0.0.0/0"
  description = "allow outbound traffic"
}
  • EC2 Instance with IAM Role and CloudWatch Logs

We launch an EC2 instance with an IAM role to write logs to CloudWatch Logs.

resource "aws_instance" "flask_app" {
  ami                         = "ami-00ffa5b66c55581f9" # Amazon Linux 2023 AMI (ARM)
  instance_type               = var.instance_type
  vpc_security_group_ids      = [aws_security_group.flask_sg.id]
  subnet_id                   = aws_subnet.public_subnet_1.id
  iam_instance_profile        = aws_iam_instance_profile.ec2_profile.name
  associate_public_ip_address = true
  key_name                    = "flask-key"

  user_data = file("${path.module}/user_data.sh")

  tags = {
    Name = "FlaskAppEC2"
  }
}

resource "aws_iam_role" "ec2_cloudwatch_role" {
  name = "CloudWatchRole"

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

resource "aws_iam_instance_profile" "ec2_profile" {
  name = "ec2-instance-profile"
  role = aws_iam_role.ec2_cloudwatch_role.name
}

resource "aws_iam_role_policy" "cloudwatch_write" {
  name = "cloudwatch_write"
  role = aws_iam_role.ec2_cloudwatch_role.id

  # Terraform's "jsonencode" function converts a
  # Terraform expression result to valid JSON syntax.
  policy = jsonencode({
    Version = "2012-10-17"
    Statement = [
      {
        Action = [
          "logs:CreateLogGroup",
          "logs:CreateLogStream",
          "logs:PutLogEvents",
          "logs:DescribeLogStreams"
        ]
        Effect   = "Allow"
        Resource = "*"
      },
    ]
  })
}

🚀 Deployment

Once everything is defined, deploy with two simple commands:

terraform init
terraform apply

✅ Final Thoughts

With this setup, you have a fully automated backend infrastructure using Terraform, ready to serve as the foundation for any backend application. You can extend this architecture later with databases, auto-scaling, and more robust CI/CD pipelines.

If you want to see how this backend can connect with a static frontend hosted on S3 and CloudFront, stay tuned for the next articles!

Thank you for reading this article. For a deeper dive and a more hands-on experience, check out the full project on GitHub repository link: https://github.com/denisgulev/application-boilerplate

🔗 Read More

For a step-by-step guide to deploying a Static Website, check out the full article:

👉 Deploy a Static Website


🚀 Ready to connect this backend to a frontend? Stay tuned for the next part!

Leave a Reply