Docker, Terraform, and GitHub Actions on AWS
Build a CI/CD pipeline that deploys a Flask app to EC2 via GitHub Actions.
Introduction
30 Second Summary
When you update an app, there is an invisible assembly line between the code on your laptop and the live version users see. Professional teams automate every step of that assembly line so deployments happen in seconds, not hours.
In this project, you will build a complete deployment pipeline that automatically packages a Python application into a Docker container, provisions cloud infrastructure with Terraform, and deploys to AWS every time you push code to GitHub.
What You'll Build
A fully automated CI/CD pipeline connecting Docker, Terraform, GitHub Actions, and AWS to deploy a containerized Flask application.
By the end of this project, you'll have:
- A production-grade multi-stage Dockerfile that builds an optimized container image running as a non-root user.
- AWS infrastructure (ECR, EC2, security groups, IAM roles) fully managed by Terraform configuration files.
- A GitHub Actions CI/CD workflow that builds, pushes, and deploys your container on every merge to main.
- Secret Mission: Configure remote state with S3 + DynamoDB locking and make terraform plan output appear as a PR comment automatically.
Are there any prerequisites?
This project assumes basic familiarity with Docker, Terraform, and Git. You need Docker Desktop, Terraform CLI, Git, and the AWS CLI already installed on Windows, plus an AWS account with free tier access.
Set Up the Project Repository
Every production DevOps pipeline starts with a well-organized repository. The way you structure your project determines how cleanly your Docker builds, Terraform plans, and GitHub Actions workflows stay separated.
In this step, you will create a project directory with clear boundaries between application code, infrastructure configuration, and CI/CD workflows. You will also initialize a Git repository, push it to GitHub, and create a feature branch following GitHub Flow.
In this step, get ready to:
- Create the project directory structure with proper separation of concerns.
- Initialize Git, configure a .gitignore, and push to a GitHub remote.
- Create a feature branch following GitHub Flow.
Create the project directory structure
A DevOps project has three distinct layers: the application code, the infrastructure definitions, and the CI/CD pipeline configuration. Keeping these in separate directories prevents coupling and makes each layer independently testable.
- Open a terminal. Press Cmd+Space (macOS) or the Windows key (Windows) to open your search bar.
- Type Git Bash (Windows) or Terminal (macOS) and press Enter to open it.
Why Git Bash?
Git Bash provides a Unix-like terminal on Windows. It was installed automatically when you installed Git. All commands in this project use bash syntax, which works in both Git Bash (Windows) and Terminal (macOS/Linux).
- Move to your Desktop by running this command:
cd ~/Desktop
- Create your project folder and its subdirectories by running these commands:
mkdir devops-pipeline
cd devops-pipeline
mkdir -p app terraform .github/workflows
What do these directories do?
- app/ holds your Flask application code and its dependencies.
- terraform/ holds all Infrastructure as Code configuration files.
- .github/workflows/ is where GitHub Actions looks for CI/CD pipeline definitions.
- Confirm the directories exist by running:
ls -la
You should see app, terraform, and .github listed in the output.
Don't see the .github folder?
Files and folders starting with a dot are hidden by default. The -la flags in ls show hidden items. If you only ran ls without flags, try again with ls -la.
help me see hidden files in my terminal
Initialize Git and push to GitHub
With the directory structure in place, you need version control tracking from the very first file. You also need a .gitignore to prevent Terraform state files and secrets from being committed.
- Initialize a Git repository with main as the default branch by running:
git init -b main
Why use -b main?
Some Git installations default to naming the initial branch master. The -b main flag explicitly names it main, which matches the branch name GitHub expects and the branch your CI/CD pipeline will trigger on.
You should see a message confirming the repository was initialized.
- Create the .gitignore file by running this command:
cat > .gitignore << 'EOF'
# Terraform
*.tfstate
*.tfstate.backup
.terraform/
.terraform.lock.hcl
terraform.tfvars
# Python
__pycache__/
*.pyc
.venv/
# Docker
.dockerignore
# IDE
.vscode/
.idea/
# OS
Thumbs.db
.DS_Store
# Secrets
*.pem
.env
EOF
What does this .gitignore protect?
- Terraform state files (*.tfstate) contain sensitive resource details and should never be committed.
- The .terraform/ directory holds downloaded provider binaries that are machine-specific.
- terraform.tfvars often contains account-specific values you do not want public.
- *.pem and .env prevent private keys and credentials from being tracked.
- Verify the file was created by running:
cat .gitignore
You should see the full list of ignore patterns printed in your terminal.
Seeing "No such file or directory"?
Make sure you are inside the devops-pipeline folder. Run pwd to check your current location. If you are not in ~/Desktop/devops-pipeline, navigate there first.
help me figure out why my .gitignore file was not created
Now you need a GitHub repository to push this to. You will create one through the GitHub website.
- Open your browser and go to github.com.
- Sign in to your GitHub account.
- Click the + icon in the top-right corner of any page.
- Click New repository.
- Enter devops-pipeline as the repository name.
- Set the visibility to Public.
- Leave all checkboxes unchecked (no README, no .gitignore, no license). You already have a local repository to push.
- Click Create repository.
Why make it public?
GitHub Actions is free and unlimited for public repositories. Private repos have a 2,000-minute monthly cap on the free plan. Keeping it public avoids hitting that limit during this project.
GitHub shows a Quick Setup page with your repository URL after creation. Copy the HTTPS URL shown on that page.
- Back in your terminal, link your local repo to GitHub and push by running these commands:
git add .
git commit -m "Initial project structure"
git remote add origin [[YOUR_REPO_URL="https://github.com/your-username/devops-pipeline.git"]]
git push -u origin main
What do these commands do?
- git add . stages all files (the .gitignore and empty directories) for commit.
- git commit -m creates a snapshot of your project at this point.
- git remote add origin connects your local repo to the GitHub remote.
- git push -u origin main uploads your commit and sets main as the default upstream branch.
- Refresh your GitHub repository page in the browser.
You should see your .gitignore file listed in the repository.
Push was rejected or authentication failed?
If Git asks for credentials, GitHub no longer accepts passwords for HTTPS. You need a Personal Access Token. Go to Settings > Developer settings > Personal access tokens > Tokens (classic) in GitHub, generate a token with repo scope, and use it as your password when prompted.
help me authenticate with GitHub from my terminal
✔️ Awesome, I've got everything!
Great. Double check you see the .gitignore file on your GitHub repository page before moving on.
ⓧ I'd like to double check the full code
Here is the complete .gitignore file for comparison:
# Terraform
*.tfstate
*.tfstate.backup
.terraform/
.terraform.lock.hcl
terraform.tfvars
# Python
__pycache__/
*.pyc
.venv/
# Docker
.dockerignore
# IDE
.vscode/
.idea/
# OS
Thumbs.db
.DS_Store
# Secrets
*.pem
.env
Create a feature branch using GitHub Flow
GitHub Flow is a branching strategy where you always develop on a feature branch, then merge to main via a Pull Request. This keeps main stable and triggers your CI/CD pipeline only on reviewed, merged code.
- Create and switch to a feature branch by running:
git checkout -b feature/initial-setup
- Verify you are on the new branch by running:
git branch
You should see * feature/initial-setup highlighted, indicating it is your active branch.
Why work on a feature branch?
In this project, your GitHub Actions deploy workflow triggers on pushes to main. By developing on feature/initial-setup, you can push work-in-progress commits without accidentally triggering a deployment. When everything is ready, you will merge via a Pull Request.
Branch not switching?
If you see an error about uncommitted changes, make sure you committed everything in the previous step with git status. Stage and commit any remaining files before switching branches.
help me switch to my feature branch
Your repository is structured, versioned, and ready for development. Next up, you will build the Flask application and package it in a production-grade Docker container.
Dockerize the Flask Application
Your repository is set up and you're developing on a feature branch. The next challenge is packaging your application so it runs identically on your laptop and in the cloud.
Docker containers solve this by bundling your code, dependencies, and runtime into a single portable image. In this step, you'll write a Flask application, then package it using a multi-stage Dockerfile optimized for production.
In this step, get ready to:
- Write the Flask application with health check and info endpoints.
- Create a production-grade multi-stage Dockerfile.
- Build and test the Docker image locally.
Write the Flask application and dependencies
Every pipeline needs an application to deploy. You'll create a lightweight Flask app with two endpoints that prove the container is running and healthy.
- Create the dependency file by adding this content to app/requirements.txt:
flask==3.1.1
- Create the Flask application by adding this code to app/app.py:
import socket
from datetime import datetime, timezone
from flask import Flask, jsonify
app = Flask(__name__)
@app.route("/")
def health():
return jsonify({"status": "healthy", "service": "devops-pipeline-demo"})
@app.route("/info")
def info():
return jsonify(
{
"hostname": socket.gethostname(),
"timestamp": datetime.now(timezone.utc).isoformat(),
"version": "1.0.0",
}
)
if __name__ == "__main__":
app.run(host="0.0.0.0", port=5000)
What does this code do?
This Flask app defines two routes that serve as signals for your deployment pipeline.
- The / route is a health check. It returns a JSON object with the service name and a "healthy" status. Load balancers and monitoring tools hit this to confirm the app is alive.
- The /info route returns the container's hostname, a UTC timestamp, and a version string. This helps you verify which container is responding after a deploy.
- The host="0.0.0.0" setting makes Flask listen on all network interfaces inside the container, which is required for Docker port mapping to work.
- Save both app/requirements.txt and app/app.py.
Seeing a red underline under flask?
Your editor may flag the flask import because the package isn't installed in your local Python environment. This is expected. The package will be installed inside the Docker container, not on your host machine.
help me understand why my editor shows import errors for Flask
✔️ Awesome, I've got everything!
Great. Make sure both files are saved before moving on.
ⓧ I'd like to double check the full code
Here is app/requirements.txt:
flask==3.1.1
Here is app/app.py:
import socket
from datetime import datetime, timezone
from flask import Flask, jsonify
app = Flask(__name__)
@app.route("/")
def health():
return jsonify({"status": "healthy", "service": "devops-pipeline-demo"})
@app.route("/info")
def info():
return jsonify(
{
"hostname": socket.gethostname(),
"timestamp": datetime.now(timezone.utc).isoformat(),
"version": "1.0.0",
}
)
if __name__ == "__main__":
app.run(host="0.0.0.0", port=5000)
Create a multi-stage Dockerfile
A multi-stage build separates the installation of dependencies from the final runtime image. This keeps your production image small and free of build tools.
- Create the Dockerfile in your project root (the same level as the app/ folder) by adding this content to a file named Dockerfile:
# Stage 1: Builder
FROM python:3.12-slim AS builder
WORKDIR /install
COPY app/requirements.txt .
RUN pip install --no-cache-dir --prefix=/install/deps -r requirements.txt
# Stage 2: Production
FROM python:3.12-slim
RUN useradd --create-home appuser
WORKDIR /home/appuser
COPY --from=builder /install/deps /usr/local
COPY app/ .
USER appuser
EXPOSE 5000
CMD ["python", "app.py"]
What does this Dockerfile do?
The Dockerfile uses two stages to produce a lean production image.
- Stage 1 (builder) installs Python packages into a custom prefix (/install/deps). This isolates installed packages from the OS so you can copy only what you need.
- Stage 2 (production) starts fresh from the same slim base. It creates a non-root user appuser for security, copies the installed packages from Stage 1, then copies your application code.
- The USER appuser directive means the container process never runs as root. This limits damage if an attacker exploits a vulnerability in your app.
Why a multi-stage build instead of a single stage?
A single-stage Dockerfile leaves build tools and cached files in your final image, inflating its size. Multi-stage builds discard everything from the builder stage except the installed packages you explicitly copy over.
The result is a smaller, faster-to-pull image with a reduced attack surface. This matters in a CI/CD pipeline where every push triggers a build, push, and pull cycle.
- Save Dockerfile.
Dockerfile not recognized by your editor?
Make sure the file is named exactly Dockerfile with a capital D and no file extension. Some editors may not syntax-highlight it until you install a Docker extension.
help me fix my Dockerfile setup
✔️ Awesome, I've got everything!
Great. Double check you've saved the file.
ⓧ I'd like to double check the full code
Here is the full Dockerfile:
# Stage 1: Builder
FROM python:3.12-slim AS builder
WORKDIR /install
COPY app/requirements.txt .
RUN pip install --no-cache-dir --prefix=/install/deps -r requirements.txt
# Stage 2: Production
FROM python:3.12-slim
RUN useradd --create-home appuser
WORKDIR /home/appuser
COPY --from=builder /install/deps /usr/local
COPY app/ .
USER appuser
EXPOSE 5000
CMD ["python", "app.py"]
Build and test the Docker image locally
With the application code and Dockerfile in place, you can build the image and run it to confirm everything works before pushing to a remote registry.
- Make sure you are in your project's root directory (the folder containing Dockerfile and the app/ folder).
- Build the Docker image by running this command:
docker build -t devops-pipeline .
What does this command do?
- docker build reads your Dockerfile and executes each instruction to assemble an image.
- -t devops-pipeline tags the resulting image with the name devops-pipeline so you can reference it easily.
- The . at the end sets the build context to the current directory. Docker sends all files in this directory to the build engine.
The build will take a minute the first time as Docker pulls the python:3.12-slim base image and installs Flask.
Build fails with a connection error?
Make sure Docker Desktop is running. If it is not started, the Docker CLI cannot reach the build engine and you will see an error about connecting to the Docker daemon.
Also confirm you are running the command from the directory that contains your Dockerfile. If Docker cannot find the file, the build will fail immediately.
help me fix my Docker build error
- Run the container by executing this command:
docker run -d -p 5000:5000 --name devops-app devops-pipeline
What does this command do?
- -d runs the container in the background (detached mode).
- -p 5000:5000 maps port 5000 on your machine to port 5000 inside the container.
- --name devops-app gives the container a friendly name so you can stop or remove it easily.
- Test the health check endpoint by running:
curl http://localhost:5000/
You should see a JSON response like this:
{"service":"devops-pipeline-demo","status":"healthy"}
- Test the info endpoint by running:
curl http://localhost:5000/info
You should see a JSON response containing a hostname, a UTC timestamp, and version 1.0.0.
Getting "connection refused" from curl?
Check that the container is actually running:
docker ps
If you don't see devops-app in the list, the container may have exited immediately after starting. Check the logs:
docker logs devops-app
Common causes: a typo in app.py that crashes Flask on startup, or port 5000 already in use by another process.
help me debug my Docker container
- Once you've confirmed both endpoints work, stop and remove the test container by running:
docker stop devops-app && docker rm devops-app
Keep your Docker environment tidy
Stopping and removing the container after testing frees up port 5000 and prevents name conflicts the next time you run docker run. The image stays on your machine for the next build.
Your application is containerized and verified locally. Next up, you'll provision the cloud infrastructure with Terraform so this container has somewhere to run in production.
Provision AWS Infrastructure with Terraform
Your Docker image runs perfectly on your machine. Now you need cloud infrastructure to host it so anyone on the internet can reach it.
Instead of clicking through the AWS Console to create each resource by hand, you will define your entire infrastructure in Terraform configuration files. This means your infrastructure is version-controlled, repeatable, and teardown is a single command.
In this step, get ready to:
- Write the Terraform provider, variables, and ECR configuration.
- Write the EC2 infrastructure including security groups, IAM roles, and the instance itself.
- Initialize Terraform and apply the configuration to provision real AWS resources.
Write the Terraform provider and ECR configuration
Every Terraform project starts with a provider block that tells Terraform which cloud platform to talk to and which version of the provider plugin to use.
- Create the Terraform directory inside your project by running this command:
mkdir terraform
- Create a new file called provider.tf inside the terraform/ folder, then paste the following code:
terraform {
required_version = ">= 1.5.0"
required_providers {
aws = {
source = "hashicorp/aws"
version = "~> 6.0"
}
}
}
provider "aws" {
region = var.aws_region
}
What does this code do?
The terraform block pins the minimum Terraform CLI version to 1.5.0 and locks the AWS provider to any 6.x release. This prevents breaking changes from a major version bump.
The provider "aws" block sets the region using a variable we will define next. Every AWS resource Terraform creates will land in that region.
- Save provider.tf.
- Create a new file called variables.tf inside terraform/, then paste this code:
variable "aws_region" {
description = "AWS region for all resources"
type = string
default = "us-east-1"
}
variable "project_name" {
description = "Project name used for resource naming"
type = string
default = "devops-pipeline"
}
What does this code do?
These variables act as reusable inputs for your Terraform configuration. aws_region controls where resources are created. project_name is used in naming every resource so they are easy to identify in the AWS Console.
- Save variables.tf.
Now you need a place to store your Docker images in the cloud. AWS ECR (Elastic Container Registry) is a private Docker registry that your EC2 instance will pull images from.
- Create a new file called ecr.tf inside terraform/, then paste this code:
resource "aws_ecr_repository" "app" {
name = var.project_name
image_tag_mutability = "MUTABLE"
image_scanning_configuration {
scan_on_push = true
}
tags = {
Project = var.project_name
ManagedBy = "terraform"
}
}
What does this code do?
This creates a private ECR repository named devops-pipeline (from var.project_name).
- image_tag_mutability = "MUTABLE" means you can push a new image with the same tag (like latest) and it overwrites the previous one.
- scan_on_push = true automatically scans each image for known vulnerabilities when you push it.
- Save ecr.tf.
Not sure where to create these files?
All three files (provider.tf, variables.tf, ecr.tf) go inside the terraform/ folder you just created. Your project structure should look like this: devops-pipeline/terraform/provider.tf.
help me understand the project folder structure for Terraform files
- Confirm the files exist by running:
ls terraform/
You should see ecr.tf, provider.tf, and variables.tf in the output.
✔️ Awesome, I've got everything!
Great. Make sure you have saved all three files before moving on.
ⓧ I'd like to double check the full code
terraform {
required_version = ">= 1.5.0"
required_providers {
aws = {
source = "hashicorp/aws"
version = "~> 6.0"
}
}
}
provider "aws" {
region = var.aws_region
}
variable "aws_region" {
description = "AWS region for all resources"
type = string
default = "us-east-1"
}
variable "project_name" {
description = "Project name used for resource naming"
type = string
default = "devops-pipeline"
}
resource "aws_ecr_repository" "app" {
name = var.project_name
image_tag_mutability = "MUTABLE"
image_scanning_configuration {
scan_on_push = true
}
tags = {
Project = var.project_name
ManagedBy = "terraform"
}
}
Write the EC2 infrastructure
Your container registry is defined. Now you need a server to actually run the container. This file defines everything the EC2 instance needs: the right Amazon Machine Image, network rules, and permissions to pull from ECR.
- Create a new file called ec2.tf inside terraform/.
- Paste the AMI data source and security group configuration:
data "aws_ami" "amazon_linux" {
most_recent = true
owners = ["amazon"]
filter {
name = "name"
values = ["al2023-ami-*-x86_64"]
}
filter {
name = "virtualization-type"
values = ["hvm"]
}
}
resource "aws_security_group" "app" {
name = "${var.project_name}-sg"
description = "Allow HTTP on 5000 and SSH"
ingress {
from_port = 5000
to_port = 5000
protocol = "tcp"
cidr_blocks = ["0.0.0.0/0"]
}
ingress {
from_port = 22
to_port = 22
protocol = "tcp"
cidr_blocks = ["0.0.0.0/0"]
}
egress {
from_port = 0
to_port = 0
protocol = "-1"
cidr_blocks = ["0.0.0.0/0"]
}
tags = {
Project = var.project_name
ManagedBy = "terraform"
}
}
What does this code do?
- The data "aws_ami" block finds the latest Amazon Linux 2023 AMI automatically. You never have to hardcode an AMI ID that might go stale.
- The security group opens port 5000 (for your Flask app) and port 22 (for SSH access) to all inbound traffic.
- The egress rule with protocol -1 allows all outbound traffic so the instance can pull Docker images and install packages.
- Below the security group, paste the IAM resources that give the EC2 instance permission to pull images from ECR:
resource "aws_iam_role" "ec2_ecr" {
name = "${var.project_name}-ec2-role"
assume_role_policy = jsonencode({
Version = "2012-10-17"
Statement = [
{
Action = "sts:AssumeRole"
Effect = "Allow"
Principal = {
Service = "ec2.amazonaws.com"
}
}
]
})
tags = {
Project = var.project_name
ManagedBy = "terraform"
}
}
resource "aws_iam_role_policy_attachment" "ecr_read" {
role = aws_iam_role.ec2_ecr.name
policy_arn = "arn:aws:iam::aws:policy/AmazonEC2ContainerRegistryReadOnly"
}
resource "aws_iam_instance_profile" "ec2" {
name = "${var.project_name}-instance-profile"
role = aws_iam_role.ec2_ecr.name
}
What does this code do?
- The IAM role uses an assume-role policy that lets the EC2 service take on this role.
- The policy attachment grants AmazonEC2ContainerRegistryReadOnly so the instance can pull (but not push) images from ECR.
- The instance profile wraps the role so it can be attached to an EC2 instance. EC2 instances cannot use IAM roles directly.
- Below the instance profile, paste the EC2 instance resource:
resource "aws_instance" "app" {
ami = data.aws_ami.amazon_linux.id
instance_type = "t2.micro"
iam_instance_profile = aws_iam_instance_profile.ec2.name
vpc_security_group_ids = [aws_security_group.app.id]
user_data = <<-EOF
#!/bin/bash
dnf install -y docker
systemctl start docker
systemctl enable docker
usermod -aG docker ec2-user
EOF
tags = {
Name = "${var.project_name}-server"
Project = var.project_name
ManagedBy = "terraform"
}
}
What does this code do?
- instance_type = "t2.micro" selects the free-tier eligible instance size (750 hours/month free for 12 months on new AWS accounts).
- The user_data script runs once when the instance first boots. It installs Docker, starts the Docker daemon, and adds the ec2-user to the docker group so it can run containers without sudo.
- The security group and IAM profile are attached here, connecting the network rules and permissions you defined above.
- Save ec2.tf.
Seeing red squiggly lines in your editor?
If your editor shows errors on var.project_name or resource references, that is normal. The Terraform language server does not resolve these until you run terraform init in the next substep.
help me understand why my editor shows errors in Terraform files before running init
Finally, you need an outputs file so Terraform prints the key values (like the ECR URL and EC2 IP address) after it creates resources.
- Create a new file called outputs.tf inside terraform/, then paste this code:
output "ecr_repository_url" {
description = "URL of the ECR repository"
value = aws_ecr_repository.app.repository_url
}
output "ec2_public_ip" {
description = "Public IP of the EC2 instance"
value = aws_instance.app.public_ip
}
output "ec2_instance_id" {
description = "ID of the EC2 instance"
value = aws_instance.app.id
}
What does this code do?
Outputs tell Terraform to print specific values after a successful apply. You will need all three of these values in the next step when you configure GitHub Actions to push images and deploy to your instance.
- Save outputs.tf.
✔️ Awesome, I've got everything!
Great. Make sure you have saved all files in your terraform folder before moving on.
ⓧ I'd like to double check the full code
terraform {
required_version = ">= 1.5.0"
required_providers {
aws = {
source = "hashicorp/aws"
version = "~> 6.0"
}
}
}
provider "aws" {
region = var.aws_region
}
variable "aws_region" {
description = "AWS region for all resources"
type = string
default = "us-east-1"
}
variable "project_name" {
description = "Project name used for resource naming"
type = string
default = "devops-pipeline"
}
resource "aws_ecr_repository" "app" {
name = var.project_name
image_tag_mutability = "MUTABLE"
image_scanning_configuration {
scan_on_push = true
}
tags = {
Project = var.project_name
ManagedBy = "terraform"
}
}
data "aws_ami" "amazon_linux" {
most_recent = true
owners = ["amazon"]
filter {
name = "name"
values = ["al2023-ami-*-x86_64"]
}
filter {
name = "virtualization-type"
values = ["hvm"]
}
}
resource "aws_security_group" "app" {
name = "${var.project_name}-sg"
description = "Allow HTTP on 5000 and SSH"
ingress {
from_port = 5000
to_port = 5000
protocol = "tcp"
cidr_blocks = ["0.0.0.0/0"]
}
ingress {
from_port = 22
to_port = 22
protocol = "tcp"
cidr_blocks = ["0.0.0.0/0"]
}
egress {
from_port = 0
to_port = 0
protocol = "-1"
cidr_blocks = ["0.0.0.0/0"]
}
tags = {
Project = var.project_name
ManagedBy = "terraform"
}
}
resource "aws_iam_role" "ec2_ecr" {
name = "${var.project_name}-ec2-role"
assume_role_policy = jsonencode({
Version = "2012-10-17"
Statement = [
{
Action = "sts:AssumeRole"
Effect = "Allow"
Principal = {
Service = "ec2.amazonaws.com"
}
}
]
})
tags = {
Project = var.project_name
ManagedBy = "terraform"
}
}
resource "aws_iam_role_policy_attachment" "ecr_read" {
role = aws_iam_role.ec2_ecr.name
policy_arn = "arn:aws:iam::aws:policy/AmazonEC2ContainerRegistryReadOnly"
}
resource "aws_iam_instance_profile" "ec2" {
name = "${var.project_name}-instance-profile"
role = aws_iam_role.ec2_ecr.name
}
resource "aws_instance" "app" {
ami = data.aws_ami.amazon_linux.id
instance_type = "t2.micro"
iam_instance_profile = aws_iam_instance_profile.ec2.name
vpc_security_group_ids = [aws_security_group.app.id]
user_data = <<-EOF
#!/bin/bash
dnf install -y docker
systemctl start docker
systemctl enable docker
usermod -aG docker ec2-user
EOF
tags = {
Name = "${var.project_name}-server"
Project = var.project_name
ManagedBy = "terraform"
}
}
output "ecr_repository_url" {
description = "URL of the ECR repository"
value = aws_ecr_repository.app.repository_url
}
output "ec2_public_ip" {
description = "Public IP of the EC2 instance"
value = aws_instance.app.public_ip
}
output "ec2_instance_id" {
description = "ID of the EC2 instance"
value = aws_instance.app.id
}
Initialize and apply Terraform
Your configuration files are ready. Now you will run three Terraform commands to turn those files into real AWS resources.
- Move into the terraform directory by running:
cd terraform
- Initialize Terraform to download the AWS provider plugin by running:
terraform init
You should see a message that says Terraform has been successfully initialized! along with a note about running terraform plan next.
Seeing a provider error?
If you see a message about failing to query available provider packages, check your internet connection. Terraform needs to download the hashicorp/aws provider plugin from the Terraform Registry.
If you see a version constraint error, confirm your provider.tf has version = "~> 6.0" exactly as shown above.
help me fix my terraform init error
- Preview what Terraform will create by running:
terraform plan
What does terraform plan show?
The plan output lists every resource Terraform intends to create, modify, or destroy. You should see a summary at the bottom saying it will add several resources (the ECR repository, security group, IAM role, instance profile, and EC2 instance).
This is a dry run. Nothing is created yet. Always review the plan before applying.
- Apply the configuration to create the real AWS resources by running:
terraform apply
- Terraform will show the plan again and ask for confirmation. Type yes and press Enter.
This takes 1-2 minutes. When it finishes, Terraform prints the outputs you defined: the ECR repository URL, the EC2 public IP, and the EC2 instance ID.
Save your output values
Copy the ecr_repository_url and ec2_instance_id values somewhere safe. You will need them in the next step when configuring your GitHub Actions workflow.
- Verify the ECR repository exists by opening the AWS Console in your browser.
- Search for ECR in the top search bar and select Elastic Container Registry.
- Click Private repositories in the left sidebar. You should see a repository named devops-pipeline.
- Verify the EC2 instance is running by searching for EC2 in the AWS Console search bar.
- Click Instances in the left sidebar. You should see an instance named devops-pipeline-server with a state of Running.
terraform apply failed?
If you see an authorization error, confirm your AWS CLI is configured with credentials that have permission to create EC2 instances, security groups, IAM roles, and ECR repositories.
If you see a region error, make sure the default value in variables.tf matches the region your AWS account is set up in.
help me debug my terraform apply error
Your cloud infrastructure is live. You have an ECR repository waiting for Docker images and an EC2 instance ready to run containers. Next, you will wire everything together with a GitHub Actions workflow that automatically builds, pushes, and deploys on every merge to main.
Create the GitHub Actions CI/CD Pipeline
Your infrastructure is live. You have an ECR repository ready to store container images and an EC2 instance ready to run them.
The missing piece is automation. Right now, deploying your app would require you to manually build the Docker image, push it to ECR, SSH into the instance, and pull it. That process is slow and error-prone. GitHub Actions solves this by running those steps automatically every time you push code to main.
In this step, get ready to:
- Write a GitHub Actions workflow that builds, pushes, and deploys your Docker image.
- Configure repository secrets so the workflow can authenticate with AWS.
- Grant your EC2 instance the permissions it needs for SSM-based deployment.
Create the workflow file with AWS credentials and ECR login
GitHub Actions workflows live in a special directory: .github/workflows/. Any YAML file in this folder becomes an automated workflow that GitHub runs based on triggers you define.
- Create the workflow directory structure by running these commands:
mkdir -p .github/workflows
- Confirm the directories exist by running:
ls .github/workflows
You should see the empty workflows directory listed without errors.
- Create the workflow file .github/workflows/deploy.yml and add the trigger configuration, environment variables, and the first three job steps (checkout, AWS credentials, and ECR login):
name: Build and Deploy
on:
push:
branches:
- main
env:
AWS_REGION: us-east-1
ECR_REPOSITORY: devops-pipeline
jobs:
deploy:
name: Build and Deploy
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Configure AWS credentials
uses: aws-actions/configure-aws-credentials@v4
with:
aws-access-key-id: ${{ secrets.AWS_ACCESS_KEY_ID }}
aws-secret-access-key: ${{ secrets.AWS_SECRET_ACCESS_KEY }}
aws-region: ${{ env.AWS_REGION }}
- name: Login to Amazon ECR
id: login-ecr
uses: aws-actions/amazon-ecr-login@v2
What does this workflow do so far?
The on: push: branches: - main trigger means this workflow runs every time code is pushed to the main branch.
The env block sets workflow-wide variables for the AWS region and ECR repository name. The three steps check out your code, authenticate with AWS using secrets you will configure shortly, and log in to your ECR registry so Docker can push images to it.
Why SSM instead of SSH for deployment?
AWS Systems Manager (SSM) lets you run commands on EC2 instances without managing SSH keys. This avoids storing private keys as GitHub secrets and teaches a production pattern that many teams use.
The trade-off is that the EC2 instance needs an additional IAM policy for SSM communication. You will attach that policy shortly.
Add the Docker build, push, and deploy steps
The next two steps handle the core pipeline logic: building your Docker image, pushing it to ECR, and then deploying it to your EC2 instance via SSM.
- In .github/workflows/deploy.yml, add the following two steps below the Login to Amazon ECR step (at the same indentation level as the other steps):
- name: Build, tag, and push image to Amazon ECR
id: build-image
env:
ECR_REGISTRY: ${{ steps.login-ecr.outputs.registry }}
IMAGE_TAG: ${{ github.sha }}
run: |
docker build -t $ECR_REGISTRY/$ECR_REPOSITORY:$IMAGE_TAG .
docker build -t $ECR_REGISTRY/$ECR_REPOSITORY:latest .
docker push $ECR_REGISTRY/$ECR_REPOSITORY:$IMAGE_TAG
docker push $ECR_REGISTRY/$ECR_REPOSITORY:latest
echo "image=$ECR_REGISTRY/$ECR_REPOSITORY:$IMAGE_TAG" >> $GITHUB_OUTPUT
- name: Deploy to EC2
env:
EC2_INSTANCE_ID: ${{ secrets.EC2_INSTANCE_ID }}
ECR_REGISTRY: ${{ steps.login-ecr.outputs.registry }}
IMAGE_TAG: ${{ github.sha }}
run: |
aws ssm send-command \
--instance-ids "$EC2_INSTANCE_ID" \
--document-name "AWS-RunShellScript" \
--parameters commands='[
"aws ecr get-login-password --region us-east-1 | docker login --username AWS --password-stdin '"$ECR_REGISTRY"'",
"docker stop devops-app || true",
"docker rm devops-app || true",
"docker pull '"$ECR_REGISTRY/$ECR_REPOSITORY:$IMAGE_TAG"'",
"docker run -d --name devops-app -p 5000:5000 '"$ECR_REGISTRY/$ECR_REPOSITORY:$IMAGE_TAG"'"
]' \
--region ${{ env.AWS_REGION }}
What do these steps do?
The build step tags the image with both the Git commit SHA (${{ github.sha }}) and latest. The SHA tag gives you an immutable record of which code produced which image. The latest tag is a convenience pointer to the most recent build.
The deploy step uses aws ssm send-command to run a shell script on your EC2 instance. The script authenticates to ECR, stops and removes the old container, pulls the new image, and starts it on port 5000.
- Save .github/workflows/deploy.yml.
YAML indentation errors?
YAML is whitespace-sensitive. Each step must be indented with exactly 6 spaces (or consistent spacing matching the rest of the file). If your editor uses tabs instead of spaces, the workflow will fail to parse.
help me fix YAML indentation in my GitHub Actions workflow
✔️ Awesome, I've got everything!
Great. Double check you have saved .github/workflows/deploy.yml before moving on.
ⓧ I'd like to double check the full code
Here is the complete workflow file. Make sure your file matches this exactly:
name: Build and Deploy
on:
push:
branches:
- main
env:
AWS_REGION: us-east-1
ECR_REPOSITORY: devops-pipeline
jobs:
deploy:
name: Build and Deploy
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Configure AWS credentials
uses: aws-actions/configure-aws-credentials@v4
with:
aws-access-key-id: ${{ secrets.AWS_ACCESS_KEY_ID }}
aws-secret-access-key: ${{ secrets.AWS_SECRET_ACCESS_KEY }}
aws-region: ${{ env.AWS_REGION }}
- name: Login to Amazon ECR
id: login-ecr
uses: aws-actions/amazon-ecr-login@v2
- name: Build, tag, and push image to Amazon ECR
id: build-image
env:
ECR_REGISTRY: ${{ steps.login-ecr.outputs.registry }}
IMAGE_TAG: ${{ github.sha }}
run: |
docker build -t $ECR_REGISTRY/$ECR_REPOSITORY:$IMAGE_TAG .
docker build -t $ECR_REGISTRY/$ECR_REPOSITORY:latest .
docker push $ECR_REGISTRY/$ECR_REPOSITORY:$IMAGE_TAG
docker push $ECR_REGISTRY/$ECR_REPOSITORY:latest
echo "image=$ECR_REGISTRY/$ECR_REPOSITORY:$IMAGE_TAG" >> $GITHUB_OUTPUT
- name: Deploy to EC2
env:
EC2_INSTANCE_ID: ${{ secrets.EC2_INSTANCE_ID }}
ECR_REGISTRY: ${{ steps.login-ecr.outputs.registry }}
IMAGE_TAG: ${{ github.sha }}
run: |
aws ssm send-command \
--instance-ids "$EC2_INSTANCE_ID" \
--document-name "AWS-RunShellScript" \
--parameters commands='[
"aws ecr get-login-password --region us-east-1 | docker login --username AWS --password-stdin '"$ECR_REGISTRY"'",
"docker stop devops-app || true",
"docker rm devops-app || true",
"docker pull '"$ECR_REGISTRY/$ECR_REPOSITORY:$IMAGE_TAG"'",
"docker run -d --name devops-app -p 5000:5000 '"$ECR_REGISTRY/$ECR_REPOSITORY:$IMAGE_TAG"'"
]' \
--region ${{ env.AWS_REGION }}
Configure GitHub secrets and IAM permissions
Your workflow references four secrets that do not exist yet. You also need to create an IAM user for GitHub Actions and grant your EC2 instance the SSM permissions required for remote command execution.
Start by attaching the SSM policy to your EC2 role so the deploy step can send commands to the instance.
- Attach the AmazonSSMManagedInstanceCore policy to your EC2 role by running:
aws iam attach-role-policy --role-name devops-pipeline-ec2-role --policy-arn arn:aws:iam::aws:policy/AmazonSSMManagedInstanceCore
Why does the EC2 instance need this policy?
The AmazonSSMManagedInstanceCore policy allows the SSM Agent on your instance to communicate with AWS Systems Manager. Without it, the aws ssm send-command in your workflow would fail because the instance cannot receive commands.
- Verify the policy is attached by running:
aws iam list-attached-role-policies --role-name devops-pipeline-ec2-role
You should see both AmazonEC2ContainerRegistryReadOnly and AmazonSSMManagedInstanceCore in the output.
Next, create an IAM user that GitHub Actions will use to authenticate with AWS. This user needs permissions to push images to ECR and send SSM commands.
- Create the IAM user by running:
aws iam create-user --user-name github-actions-deployer
- Create an inline policy granting the necessary permissions by running:
aws iam put-user-policy --user-name github-actions-deployer --policy-name DeployPolicy --policy-document '{
"Version": "2012-10-17",
"Statement": [
{
"Effect": "Allow",
"Action": [
"ecr:GetAuthorizationToken",
"ecr:BatchCheckLayerAvailability",
"ecr:GetDownloadUrlForLayer",
"ecr:BatchGetImage",
"ecr:PutImage",
"ecr:InitiateLayerUpload",
"ecr:UploadLayerPart",
"ecr:CompleteLayerUpload"
],
"Resource": "*"
},
{
"Effect": "Allow",
"Action": [
"ssm:SendCommand",
"ssm:GetCommandInvocation"
],
"Resource": "*"
},
{
"Effect": "Allow",
"Action": [
"ec2:DescribeInstances"
],
"Resource": "*"
}
]
}'
What permissions does this policy grant?
The policy gives the GitHub Actions user three groups of permissions. ECR permissions allow pushing container images. SSM permissions allow sending commands to instances. EC2 describe lets the workflow look up instance details.
- Create access keys for the new user by running:
aws iam create-access-key --user-name github-actions-deployer
Save these credentials immediately
This command outputs an AccessKeyId and SecretAccessKey. Copy both values now. The secret access key is only shown once and cannot be retrieved later.
Now retrieve your EC2 instance ID from the Terraform outputs.
- Get your instance ID by running:
cd terraform && terraform output ec2_instance_id
Copy the instance ID value. You will need it for the next step.
- Return to your project root by running:
cd ..
Finally, add the four secrets to your GitHub repository so the workflow can authenticate.
- In your browser, navigate to your GitHub repository.
- Click **Settings**.
- In the left sidebar under the **Security** section, select **Secrets and variables**, then click **Actions**.
- Click the **Secrets** tab.
- Click **New repository secret**.
- In the **Name** field, type AWS_ACCESS_KEY_ID.
- In the **Secret** field, paste the AccessKeyId value from the previous step.
- Click **Add secret**.
- Repeat the process to add three more secrets:
After adding all four secrets, you should see them listed in the repository secrets page.
Secrets not appearing?
Make sure you are on the **Secrets** tab (not Variables) under **Settings > Secrets and variables > Actions**. If you accidentally created a variable instead of a secret, delete it and recreate it as a secret.
help me troubleshoot GitHub repository secrets configuration
Your pipeline is fully wired up. Next, you will trigger it by merging your feature branch and watch the entire build-push-deploy cycle run automatically.
Deploy End-to-End with GitHub Flow
Your infrastructure is provisioned, your workflow file is ready, and your secrets are configured. Now it is time to put the entire pipeline to the test.
In this step, you will follow GitHub Flow end-to-end: push your feature branch, open a Pull Request, merge to main, and watch the CI/CD pipeline build, push, and deploy your application automatically.
In this step, get ready to:
- Push your feature branch and open a Pull Request on GitHub.
- Merge the PR to main and watch the GitHub Actions pipeline deploy your app.
- Verify the live application and confirm automatic redeployment on code changes.
Push your feature branch and open a Pull Request
A Pull Request is the gateway between your feature branch and the main branch. It gives you a chance to review all the infrastructure-as-code changes, Dockerfile, and workflow configuration before triggering the pipeline.
- Commit any remaining changes and push your feature branch to GitHub by running these commands:
git add -A
git commit -m "Add Flask app, Dockerfile, Terraform config, and CI/CD workflow"
git push -u origin feature/initial-setup
What do these commands do?
- git add -A stages all new, modified, and deleted files.
- git commit saves the staged changes as a snapshot in your local repository.
- git push -u origin feature/initial-setup uploads your branch to GitHub and sets it as the upstream tracking branch.
- Open your GitHub repository in your browser.
- You should see a banner near the top saying your branch had recent pushes. Click Compare & pull request.
- Verify the base branch is set to main and the compare branch is feature/initial-setup.
- Add a title like Add complete DevOps pipeline and an optional description.
- Click Create pull request.
Why review the diff?
Scroll through the Files changed tab on your PR. This is exactly what a teammate would review on a real team. You can spot misconfigurations in your Terraform files, missing Dockerfile layers, or incorrect workflow triggers before they hit production.
Don't see the compare banner?
If the banner does not appear, click the Pull requests tab at the top of your repository, then click New pull request. Select feature/initial-setup as the compare branch.
help me open a Pull Request on GitHub
Merge the PR and monitor the pipeline
Merging to main is the trigger that kicks off your entire build-push-deploy cycle. The workflow file you created listens for pushes to the main branch.
- On your Pull Request page, scroll down and click Merge pull request.
- Click Confirm merge.
- Click the Actions tab at the top of your repository.
- You should see a new workflow run named Build and Deploy in progress. Click on it to open the details.
- Click the Build and Deploy job to see each step expand in real time.
What should I see in the Actions tab?
Each step in your workflow file appears as an expandable row. You will see steps like Checkout, Configure AWS credentials, Login to Amazon ECR, Build, tag, and push image to Amazon ECR, and Deploy to EC2.
A green checkmark next to each step means it completed successfully. The entire run typically takes 2-4 minutes.
- Wait for all steps to show green checkmarks. The workflow run status at the top changes to a green circle with a checkmark when complete.
Seeing a red X on a step?
Click the failed step to expand its logs. Common issues include:
- Credential errors on the Configure AWS credentials step mean your GitHub repository secrets are incorrect. Double-check AWS_ACCESS_KEY_ID and AWS_SECRET_ACCESS_KEY in your repository settings.
- Permission errors on the Deploy to EC2 step mean your IAM user lacks ssm:SendCommand permission or the EC2_INSTANCE_ID secret is wrong.
- Docker build failures usually mean a typo in your Dockerfile or requirements.txt. Check the error message in the logs.
help me debug a failed GitHub Actions workflow run
Verify the live application and test automatic redeploy
The pipeline has pushed your Docker image to ECR and deployed it to your EC2 instance. Now you will confirm the application is actually serving traffic.
- Find your EC2 instance's public IP. You can get it from the Terraform output by running this in your terminal:
cd terraform
terraform output ec2_public_ip
- Open your browser and navigate to http://your EC2 public IP:5000.
- You should see a JSON response: {"service":"devops-pipeline-demo","status":"healthy"}.
- Navigate to http://your EC2 public IP:5000/info to confirm the /info endpoint responds with hostname, timestamp, and version data.
Why two endpoints?
The / endpoint acts as a health check. Load balancers and monitoring tools use it to confirm the service is alive. The /info endpoint returns runtime details like hostname and timestamp, which helps you verify that a new container is actually running after each deploy.
Site not loading?
Give it 1-2 minutes after the workflow completes. The SSM command needs time to pull the image and start the container on your EC2 instance.
If it still does not load, verify your security group allows inbound traffic on port 5000. Also confirm the EC2 instance is running (not stopped) in the AWS console.
help me troubleshoot why my Flask app is not accessible on EC2
Now test that the pipeline redeploys automatically when you push a new change.
- Return to your terminal and navigate back to the project root:
cd ..
- Open app/app.py in your editor.
- Find this line inside the health() function:
return jsonify({"status": "healthy", "service": "devops-pipeline-demo"})
- Change devops-pipeline-demo to devops-pipeline-demo-v2 so the line now looks like this:
return jsonify({"status": "healthy", "service": "devops-pipeline-demo-v2"})
- Save app/app.py.
- Commit and push this change directly to main by running:
git checkout main
git pull origin main
git add app/app.py
git commit -m "Update service name to v2"
git push origin main
What do these commands do?
You switch to the main branch, pull the merged changes from GitHub, stage your edit, commit it, and push it. Because your workflow triggers on pushes to main, this push automatically starts a new pipeline run.
- Switch back to the Actions tab in your GitHub repository.
- You should see a new Build and Deploy workflow run triggered by your push.
- Wait for it to complete with green checkmarks.
- Refresh your browser at http://your EC2 public IP:5000.
- You should now see the updated response: {"service":"devops-pipeline-demo-v2","status":"healthy"}.
That confirms your end-to-end pipeline is fully automated. Every push to main triggers a fresh build, pushes the image to ECR, and deploys the new container on your EC2 instance.
What is GitHub Flow?
GitHub Flow is a lightweight branching strategy. You create a feature branch, make changes, open a Pull Request for review, merge to main, and let automation handle the deploy. It is simpler than GitFlow and works well for continuous delivery.
Secret mission
Add Remote State and Plan-on-PR
Your Terraform state is stored locally right now. In production, teams store state remotely so multiple engineers can collaborate safely. In this secret mission, you will configure S3 + DynamoDB for remote state with locking, and add a second GitHub Actions workflow that automatically posts terraform plan output as a comment on every Pull Request.
Clean Up Your Resources
Clean Up Your Resources
Decide whether to keep your resources running, pause them to come back later, or delete them entirely.
This project uses AWS resources that fall under the Free Tier for accounts less than 12 months old. If your account is older, the EC2 instance charges approximately $0.0116/hour while running.
Cost warning
Your EC2 t2.micro instance consumes free tier hours while running (750 hours/month for the first 12 months). If your AWS account is older than 12 months, you will be charged ~$0.0116/hour for the running instance.
If you are not actively using this project, choose Pause or Delete below to avoid unexpected charges.
Resources you used:
- AWS ECR repository (devops-pipeline). 500 MB free storage for 1 year, then $0.10/GB/month.
- AWS EC2 t2.micro instance (devops-pipeline-server). 750 hours/month free for 12 months, then ~$0.0116/hour.
- AWS Security Group (devops-pipeline-sg). Free.
- AWS IAM Role, Instance Profile, and IAM User (GitHub Actions). Free.
- AWS S3 bucket (devops-pipeline-tfstate). Secret Mission only. Negligible cost.
- AWS DynamoDB table (devops-pipeline-tflock). Secret Mission only. Covered by free tier.
- GitHub repository and local project files.
Keep everything running
No action needed. Choose this if you are still actively building or experimenting with the pipeline.
- Your EC2 instance continues running and consuming free tier hours. Monitor usage in the AWS Billing dashboard.
- GitHub Actions workflows will trigger on future pushes to main or PRs touching the terraform/ directory.
- Your Docker images remain stored in ECR.
Pause - I'll come back to this later
Stop the EC2 instance to eliminate compute charges while keeping all your resources intact. EBS storage charges still apply (minimal for the default volume).
- Open the AWS Console and navigate to EC2 > Instances.
- Select the devops-pipeline-server instance.
- Click Instance state > Stop instance.
- When you return, start the instance from the same menu. Note that the public IP will change after a stop/start cycle.
Delete - I don't want to use this again
Remove all AWS resources, the GitHub repository, and local files. This is not reversible.
Step 1: Destroy Terraform-managed resources.
- Open your terminal and navigate to the terraform/ directory inside your project folder.
- Run the following command to destroy all Terraform-managed AWS resources (ECR repository, EC2 instance, security group, IAM role, and instance profile):
terraform destroy
- Type yes when prompted to confirm.
Step 2: Delete Secret Mission resources (if you completed it).
- Delete the S3 state bucket by running:
aws s3 rb s3://devops-pipeline-tfstate --force
- Delete the DynamoDB lock table by running:
aws dynamodb delete-table --table-name devops-pipeline-tflock
Step 3: Delete the IAM user created for GitHub Actions.
- Open the AWS Console and navigate to IAM > Users.
- Select the checkbox next to the IAM user you created for GitHub Actions.
- Click Delete and type the username to confirm.
Step 4: Delete the GitHub repository.
- In your GitHub repository, click Settings.
- Scroll to the bottom and find the Danger Zone section.
- Click Delete this repository and follow the confirmation prompts.
Step 5: Delete local project files.
- Remove your project folder by running the appropriate command for your OS:
macOS
cd ~/Desktop
rm -rf devops-pipeline
Windows
cd ~\Desktop
rmdir /s /q devops-pipeline
That's a Wrap!
That's a Wrap!
Nice work! You just built a fully automated DevOps pipeline that takes code from a Git repository, packages it into a Docker container, provisions cloud infrastructure with Terraform, and deploys it to AWS on every push to main.
You've learned how to:
- Build a production-grade multi-stage Dockerfile that runs as a non-root user and deploys a Flask application.
- Provision AWS infrastructure (ECR, EC2, Security Groups, and IAM) entirely through Terraform configuration files.
- Create a GitHub Actions CI/CD pipeline that automatically builds, pushes to ECR, and deploys to EC2 on every merge to main using GitHub Flow.
- Secret Mission: Configured Terraform remote state with S3 + DynamoDB locking and automated terraform plan comments on PRs.
Ready to quiz yourself?