Flask CI/CD Dual Environment Deployment


Overview

I built a complete CI/CD pipeline demonstrating automated Docker containerization and multi-environment deployment to AWS using GitHub Actions.

You can find the code files in this GITHUB REPO. To run it you will need:

  • Terraform installed
  • Docker installed locally
  • GitHub account
  • AWS CLI configured

Check out the project's readme file for instructions on how to download it and run it on your machine


Architecture

Network Topology components

  • 1 Region: EU-WEST-1 (Ireland)
  • 1 VPC
  • 1 subnet
  • 1 Route Table
  • IGW
  • 1 Security Group
  • 2 x ec2 (Prod and Dev)
  • ECR
  • IAM

Code Snippets


Dockerfile

									
FROM python:3.11-slim
WORKDIR /app
COPY requirements.txt ./
RUN pip install -r requirements.txt
COPY app.py .
COPY templates/ ./templates/
COPY static/ ./static/
CMD ["python", "app.py"]
									  
								

Github Actions

									
- name: Deploy to PROD
    env:
        REGISTRY: ${{ steps.login-ecr.outputs.registry }}
        REPOSITORY: flask-cicd-dual-env-deploy
    run: |
        echo "${{ secrets.EC2_SSH_PRIVATE_KEY }}" > private_key.pem
        chmod 600 private_key.pem
        ssh -o StrictHostKeyChecking=no -i private_key.pem ubuntu@${{ secrets.PROD_EC2_IP }} "
          aws ecr get-login-password --region eu-west-1 | docker login --username AWS --password-stdin $REGISTRY
          docker pull $REGISTRY/$REPOSITORY:latest
          docker stop flask-prod || true
          docker rm flask-prod || true
          docker run -d -p 5000:5000 -e ENVIRONMENT=PROD --name flask-prod $REGISTRY/$REPOSITORY:latest
        "
        rm -f private_key.pem
									
								

Terraform IAM

	
resource "aws_iam_role" "ec2_role" {
  name = "ec2_ecr_role"

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

	

Challenges & Key Takeaways

WORKFLOW SEQUENCING: A critical learning point was understanding the distinction between one-time infrastructure provisioning and continuous deployment automation. Initially, I assumed GitHub Actions would handle everything end-to-end, but I learned that Terraform creates the foundation once (VPC, EC2 instances, ECR repository, security groups), while GitHub Actions handles the repetitive deployment cycle triggered on every git push. Each GitHub Actions step must execute in precise order: checkout code, configure AWS credentials, login to ECR, build the Docker image, run tests, push to ECR, then finally deploy to both environments. Understanding this sequential dependency—where later steps rely on outputs from earlier ones, such as using the ECR registry URL from the login step—was essential for building a reliable pipeline.

WORKFLOW SEQUENCING: A critical learning point was understanding the distinction between one-time infrastructure provisioning and continuous deployment automation. Initially, I assumed GitHub Actions would handle everything end-to-end, but I learned that Terraform creates the foundation once (VPC, EC2 instances, ECR repository, security groups), while GitHub Actions handles the repetitive deployment cycle triggered on every git push. Each GitHub Actions step must execute in precise order: checkout code, configure AWS credentials, login to ECR, build the Docker image, run tests, push to ECR, then finally deploy to both environments. Understanding this sequential dependency—where later steps rely on outputs from earlier ones, such as using the ECR registry URL from the login step—was essential for building a reliable pipeline.

CONTAINERIZATION CLARITY: Working with Docker revealed the importance of understanding the relationship between images and containers. A Docker image is a static blueprint built from the Dockerfile, while a container is a running instance of that image. This distinction became critical during deployments: GitHub Actions builds the image once and pushes it to ECR, but each EC2 instance pulls that same image and runs it as a separate container with different environment variables (DEV vs PROD). The deployment step needed logic to stop and remove existing containers before starting new ones, using '|| true' to handle cases where no previous container existed. This taught me that containerization isn't just about packaging an app—it's about managing the lifecycle of ephemeral, reproducible runtime environments.