Multi-Cloud Containers with Terraform
Deploy the same container to AWS ECS Fargate and GCP Cloud Run with Terraform.
Introduction
30 Second Summary
Relying on a single cloud provider is like having only one road into your city. If that road closes, everything stops. Organizations spread their workloads across multiple clouds to stay resilient and avoid vendor lock-in.
In this project, you will containerize a Python app and deploy it to both AWS and GCP using Terraform. You'll create a single infrastructure-as-code workflow that manages resources across two cloud providers from one command.
What You'll Build
A Python Flask application containerized with Docker and deployed to both AWS ECS Fargate and GCP Cloud Run through a modular Terraform configuration.
By the end of this project, you'll have:
- A containerized Python app pushed to both AWS ECR and GCP Artifact Registry.
- A modular Terraform codebase with separate cloud-specific modules for AWS and GCP.
- Two live public endpoints serving the same application from different cloud providers, deployed with a single terraform apply.
- Secret Mission: Add an Application Load Balancer in front of your ECS Fargate service for production-grade traffic management.
Are there any prerequisites?
This project assumes you have an AWS account and a GCP account with billing enabled. You should also be comfortable with the basics of Terraform and Docker before starting.
Build the Python Container App
Your development environment is ready with all the tools you need. Now it's time to build the actual application that will run on both clouds.
In this step, you'll create a Flask web application packaged in a Docker container. The app reads an environment variable to identify which cloud it's running on. This means the same container image works on both AWS and GCP without any code changes.
In this step, get ready to:
- Create the project directory structure with separate folders for application code and infrastructure.
- Write a Flask app that returns cloud-aware JSON responses.
- Build and test the container image locally.
Create the project directory and Flask app
The project needs two top-level directories. The app/ folder holds the Python application code and Docker configuration. The terraform/ folder will hold all the infrastructure-as-code you write later.
- Move to your Desktop by running this command:
cd ~/Desktop
- Create your project folder with both subdirectories by running these commands:
mkdir -p multicloud-deploy/app multicloud-deploy/terraform
cd multicloud-deploy
- Confirm the directories exist by running:
ls
You should see app and terraform listed in the output.
Now you'll write the Flask application. This is the code that runs inside your container on both clouds.
- Create the Flask app file by running:
touch app/app.py
- Open app/app.py in your editor and paste in the following code:
import os
from flask import Flask, jsonify
# Create the Flask application instance
app = Flask(__name__)
@app.route("/")
def hello():
# Read the cloud provider from the environment variable
cloud = os.environ.get("CLOUD_PROVIDER", "unknown")
return jsonify({
"cloud": cloud,
"message": f"Hello from {cloud}!",
"service": "multicloud-app"
})
@app.route("/health")
def health():
# Simple health check endpoint for container orchestrators
return jsonify({"status": "healthy"}), 200
if __name__ == "__main__":
# Allow port override via PORT env var (used by some cloud platforms)
port = int(os.environ.get("PORT", 8080))
app.run(host="0.0.0.0", port=port)
What does this code do?
The app has two endpoints. The / route reads the CLOUD_PROVIDER environment variable and returns it in a JSON response. This is how you'll confirm which cloud each deployment is running on.
The /health route returns a simple 200 OK. Both ECS Fargate and Cloud Run use health checks to know if your container is alive.
The app also needs a dependencies file so Docker knows what Python packages to install.
- Create the requirements file by running:
touch app/requirements.txt
- Open app/requirements.txt and paste in these two dependencies:
flask==3.1.1
gunicorn==23.0.0
What is Gunicorn?
Gunicorn is a production-grade Python web server. Flask's built-in server is only meant for development. Gunicorn handles multiple requests efficiently and is the standard way to serve Flask apps in containers.
- Save both app/app.py and app/requirements.txt.
Files not appearing in your editor?
Make sure you ran the mkdir and cd commands from above. Your terminal must be inside the multicloud-deploy folder for the relative paths to work. Run pwd to check your current location.
help me find my project files
Write the Dockerfile
A Dockerfile is a recipe that tells Docker how to package your application into a container image. This image is what you'll push to both AWS and GCP registries later.
- Create the Dockerfile by running:
touch app/Dockerfile
- Open app/Dockerfile and paste in the following:
# Use official Python slim image for small container size
FROM python:3.12-slim
WORKDIR /app
# Install dependencies first for better Docker layer caching
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
# Copy application code
COPY . .
EXPOSE 8080
# Run with Gunicorn production server
CMD ["gunicorn", "--bind", "0.0.0.0:8080", "app:app"]
What does this Dockerfile do?
- FROM python:3.12-slim starts from a lightweight Python image (about 150 MB instead of 900 MB for the full image).
- The requirements.txt is copied and installed before the app code. This means Docker can cache the dependency layer and skip reinstalling packages when only your code changes.
- CMD tells the container to start Gunicorn serving your Flask app on port 8080.
- Save app/Dockerfile.
Dockerfile has no file extension?
That's correct. Dockerfiles are named exactly Dockerfile with no extension. If your editor adds .txt or another extension, rename the file to remove it.
help me with my Dockerfile
Build and test the container locally
Before pushing anything to the cloud, you'll verify the container works on your own machine. This catches issues early.
- Build the Docker image by running this command from the multicloud-deploy directory:
docker build -t multicloud-app ./app
What does this command do?
-t multicloud-app names the image multicloud-app so you can reference it easily. ./app tells Docker to look for the Dockerfile inside the app/ directory.
Docker will download the python:3.12-slim base image, install Flask and Gunicorn, then package everything into your image. This may take a minute on the first run.
- Run the container locally by executing:
docker run -p 8080:8080 -e CLOUD_PROVIDER=local multicloud-app
What do these flags do?
- -p 8080:8080 maps port 8080 on your machine to port 8080 inside the container.
- -e CLOUD_PROVIDER=local sets the environment variable so the app responds with "cloud": "local" in its JSON output.
- Open a new terminal window or tab.
- Test the running container by sending a request:
curl http://localhost:8080
You should see a JSON response like this:
{"cloud":"local","message":"Hello from local!","service":"multicloud-app"}
- Test the health endpoint by running:
curl http://localhost:8080/health
You should see:
{"status":"healthy"}
- Go back to the terminal where the container is running and press Ctrl+C to stop it.
Port already in use?
If you see an error about port 8080 being unavailable, another process is using that port. Stop it by running docker ps to find any running containers, then docker stop <container_id> to free up the port.
help me fix a port conflict on 8080
✔️ Awesome, I've got everything!
Great. Double check you've saved all three files in your app/ folder.
ⓧ I'd like to double check the full code
Here's what each file should contain at this point.
app/app.py:
import os
from flask import Flask, jsonify
app = Flask(__name__)
@app.route("/")
def hello():
cloud = os.environ.get("CLOUD_PROVIDER", "unknown")
return jsonify({
"cloud": cloud,
"message": f"Hello from {cloud}!",
"service": "multicloud-app"
})
@app.route("/health")
def health():
return jsonify({"status": "healthy"}), 200
if __name__ == "__main__":
port = int(os.environ.get("PORT", 8080))
app.run(host="0.0.0.0", port=port)
app/requirements.txt:
flask==3.1.1
gunicorn==23.0.0
app/Dockerfile:
FROM python:3.12-slim
WORKDIR /app
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
COPY . .
EXPOSE 8080
CMD ["gunicorn", "--bind", "0.0.0.0:8080", "app:app"]
Your container is working locally. Next up, you'll push this image to both AWS and GCP container registries so Terraform can deploy it to each cloud.
Push Container Images to Both Registries
You have a working container image running locally. Now you need to store that image somewhere both cloud providers can pull from.
Each cloud has its own container registry. AWS uses ECR (Elastic Container Registry) and GCP uses Artifact Registry. In this step, you will push your image to both registries so that Terraform can deploy it to each cloud later.
In this step, get ready to:
- Push your container image to AWS ECR.
- Push the same image to GCP Artifact Registry.
- Verify both images are accessible from the CLI.
Create an ECR repository and push your image
AWS ECR requires you to create a repository before pushing an image. Think of an ECR repository as a named storage location for one container image and its tags.
- Create an ECR repository by running this command:
aws ecr create-repository --repository-name multicloud-app --region us-east-1
What does this command do?
This creates a private container image repository named multicloud-app in the us-east-1 region. The output includes a repositoryUri that you will use for tagging and pushing.
- Copy the repositoryUri value from the output. It looks like 123456789012.dkr.ecr.us-east-1.amazonaws.com/multicloud-app. You will need this shortly.
- Authenticate Docker to ECR by running this command:
aws ecr get-login-password --region us-east-1 | docker login --username AWS --password-stdin [[AWS_ACCOUNT_ID="your 12-digit AWS account ID"]].dkr.ecr.us-east-1.amazonaws.com
What does this command do?
The aws ecr get-login-password command retrieves a temporary authentication token. That token is piped into docker login so Docker can push images to your ECR registry. The token is valid for 12 hours.
- You should see Login Succeeded in the output.
- Tag your local image with the ECR repository URI by running:
docker tag multicloud-app:latest [[AWS_ACCOUNT_ID="your 12-digit AWS account ID"]].dkr.ecr.us-east-1.amazonaws.com/multicloud-app:latest
- Push the tagged image to ECR by running:
docker push [[AWS_ACCOUNT_ID="your 12-digit AWS account ID"]].dkr.ecr.us-east-1.amazonaws.com/multicloud-app:latest
You should see Docker upload layers and finish with a latest: digest: sha256:... message confirming the push succeeded.
Getting an authorization error?
Make sure you replaced the account ID placeholder with your actual 12-digit AWS account ID. You can find it by running aws sts get-caller-identity.
If you see a "no basic auth credentials" error, the login token may have expired. Re-run the aws ecr get-login-password command again.
Help me troubleshoot my ECR push error
Create an Artifact Registry repository and push your image
GCP Artifact Registry is Google's managed container registry. You will create a Docker-format repository and push the same image there.
- Create an Artifact Registry repository by running this command:
gcloud artifacts repositories create multicloud-app --repository-format=docker --location=us-central1
What does this command do?
This creates a Docker-format repository named multicloud-app in the us-central1 region. Artifact Registry supports multiple formats (Docker, npm, Maven). Specifying --repository-format=docker tells GCP this will store container images.
- Authenticate Docker to Artifact Registry by running:
gcloud auth configure-docker us-central1-docker.pkg.dev
What does this command do?
This registers gcloud as a Docker credential helper for the us-central1-docker.pkg.dev hostname. After this, Docker will use your gcloud credentials automatically when pushing to or pulling from that registry.
- When prompted to confirm adding the registry to your Docker config, type Y and press Enter.
- Tag your local image with the Artifact Registry URI by running:
docker tag multicloud-app:latest us-central1-docker.pkg.dev/[[GCP_PROJECT_ID="your-gcp-project-id"]]/multicloud-app/multicloud-app:latest
- Push the tagged image to Artifact Registry by running:
docker push us-central1-docker.pkg.dev/[[GCP_PROJECT_ID="your-gcp-project-id"]]/multicloud-app/multicloud-app:latest
You should see Docker upload layers and finish with a digest confirmation message.
Find your GCP project ID
If you are unsure of your GCP project ID, run gcloud config get-value project to see the currently configured project.
Getting a permission denied error?
Make sure you replaced the project ID placeholder with your actual GCP project ID. The URI format is us-central1-docker.pkg.dev/YOUR-PROJECT-ID/multicloud-app/multicloud-app:latest.
If you see a "denied" error, verify that the Artifact Registry API is enabled by running gcloud services list --enabled and checking for artifactregistry.googleapis.com in the list.
Help me fix my Artifact Registry push error
Verify both images are accessible
Before moving on to Terraform, confirm that both registries have your image stored and ready to pull.
- Verify the ECR image by running:
aws ecr describe-images --repository-name multicloud-app --region us-east-1
- You should see JSON output containing an imageDigest and imageTags showing latest.
- Verify the Artifact Registry image by running:
gcloud artifacts docker images list us-central1-docker.pkg.dev/[[GCP_PROJECT_ID="your-gcp-project-id"]]/multicloud-app
- You should see a table listing your multicloud-app image with the latest tag.
Not seeing your image?
If the ECR command returns an empty imageDetails array, the push may not have completed. Re-run docker push with the ECR URI and watch for errors.
If the Artifact Registry command returns no results, double-check that the project ID in your URI matches the output of gcloud config get-value project.
Help me verify my container images in both registries
Both registries now have your container image ready to deploy. Next up, you will write the Terraform modules that pull these images and run them as services on each cloud.
Write the Terraform GCP Cloud Run Module
Your container images are sitting in both registries, ready to be deployed. Now it's time to write the Terraform code that tells each cloud provider how to run them.
You'll start with GCP's Cloud Run because it's the simpler of the two deployments. Cloud Run handles networking for you automatically, so the module focuses on defining the container service and making it publicly accessible.
In this step, get ready to:
- Create a Terraform module with variables, resource definitions, and outputs for GCP Cloud Run.
- Define a Cloud Run v2 service resource that deploys your container image.
- Configure public access so anyone can reach the service URL.
Create the module directory and variables
Terraform modules are self-contained directories with their own variables, resources, and outputs. Each cloud provider gets its own module so you can develop, test, and reuse them independently.
- Create the GCP Cloud Run module directory by running this command:
mkdir -p terraform/modules/gcp-cloudrun
- Confirm the directory was created by running:
ls terraform/modules/gcp-cloudrun
The directory should exist (and be empty for now).
Every Terraform module needs a variables.tf file that declares what inputs the module expects from whoever calls it.
- Create terraform/modules/gcp-cloudrun/variables.tf and add the following code:
variable "project_id" {
description = "GCP project ID"
type = string
}
variable "region" {
description = "GCP region"
type = string
}
variable "app_name" {
description = "Application name"
type = string
}
variable "image_uri" {
description = "Container image URI from Artifact Registry"
type = string
}
What does this code do?
This file declares four input variables your module needs:
- project_id identifies which GCP project to deploy into.
- region sets where the service runs (you'll pass us-central1 later).
- app_name gives the Cloud Run service its name.
- image_uri points to the container image in Artifact Registry.
- Save variables.tf.
Now you'll define the main Cloud Run service resource. This is the core of the module.
- Create terraform/modules/gcp-cloudrun/main.tf and add the following code:
resource "google_cloud_run_v2_service" "app" {
name = var.app_name
location = var.region
deletion_protection = false
ingress = "INGRESS_TRAFFIC_ALL"
template {
containers {
image = var.image_uri
ports {
container_port = 8080
}
env {
name = "CLOUD_PROVIDER"
value = "gcp"
}
resources {
limits = {
cpu = "1"
memory = "512Mi"
}
}
}
scaling {
max_instance_count = 2
}
}
}
What does this code do?
This defines a google_cloud_run_v2_service resource. The v2 resource is the current recommended resource type for Cloud Run services in Terraform.
- ingress = "INGRESS_TRAFFIC_ALL" allows all traffic including internet requests to reach the service.
- deletion_protection = false lets Terraform destroy this resource later during cleanup.
- The CLOUD_PROVIDER environment variable tells your Flask app which cloud it's running on, so the JSON response says "gcp".
- max_instance_count = 2 caps scaling to avoid unexpected costs during testing.
- Save main.tf. You'll add one more resource to this file in the next substep.
Seeing a syntax highlighting error?
Make sure you have the HashiCorp Terraform extension installed in VS Code for .tf file support. Also check that all opening braces have matching closing braces.
Help me check my Terraform HCL syntax in main.tf for the Cloud Run module.
Make the service publicly accessible
By default, Cloud Run services are private. You need an IAM policy that grants the invoker role to all users so anyone can hit the service URL.
- In terraform/modules/gcp-cloudrun/main.tf, add the following resource below the google_cloud_run_v2_service block:
resource "google_cloud_run_v2_service_iam_member" "public" {
project = var.project_id
location = var.region
name = google_cloud_run_v2_service.app.name
role = "roles/run.invoker"
member = "allUsers"
}
What does this code do?
This resource grants the roles/run.invoker role to allUsers. That means anyone on the internet can send HTTP requests to your Cloud Run service.
The name field references google_cloud_run_v2_service.app.name so Terraform automatically connects this IAM policy to the service defined above.
- Save main.tf.
The last file in the module defines what information it exposes to the rest of your Terraform configuration.
- Create terraform/modules/gcp-cloudrun/outputs.tf and add the following code:
output "service_url" {
description = "The URL of the Cloud Run service"
value = google_cloud_run_v2_service.app.uri
}
What does this code do?
This output captures the deployed service's public URL using the .uri attribute. After deployment, Terraform prints this URL so you can immediately test the endpoint.
- Save outputs.tf.
- Verify your module structure by running:
ls terraform/modules/gcp-cloudrun/
You should see three files listed: main.tf, outputs.tf, and variables.tf.
Missing a file?
Double check that you created all three files inside terraform/modules/gcp-cloudrun/ and not in the terraform/ directory directly. The module path must be exact for Terraform to find it later.
Help me check my Terraform module directory structure for the GCP Cloud Run module.
✔️ Awesome, I've got everything!
Great. Make sure all three files are saved before moving on.
ⓧ I'd like to double check the full code
Here is the complete code for each file in terraform/modules/gcp-cloudrun/:
variables.tf:
variable "project_id" {
description = "GCP project ID"
type = string
}
variable "region" {
description = "GCP region"
type = string
}
variable "app_name" {
description = "Application name"
type = string
}
variable "image_uri" {
description = "Container image URI from Artifact Registry"
type = string
}
main.tf:
resource "google_cloud_run_v2_service" "app" {
name = var.app_name
location = var.region
deletion_protection = false
ingress = "INGRESS_TRAFFIC_ALL"
template {
containers {
image = var.image_uri
ports {
container_port = 8080
}
env {
name = "CLOUD_PROVIDER"
value = "gcp"
}
resources {
limits = {
cpu = "1"
memory = "512Mi"
}
}
}
scaling {
max_instance_count = 2
}
}
}
resource "google_cloud_run_v2_service_iam_member" "public" {
project = var.project_id
location = var.region
name = google_cloud_run_v2_service.app.name
role = "roles/run.invoker"
member = "allUsers"
}
outputs.tf:
output "service_url" {
description = "The URL of the Cloud Run service"
value = google_cloud_run_v2_service.app.uri
}
Your GCP module is ready to deploy a publicly accessible Cloud Run service. Next up, you'll write the AWS ECS Fargate module, which requires more networking configuration but follows the same modular pattern.
Write the Terraform AWS ECS Fargate Module
Your GCP Cloud Run module is ready. Now you need the AWS side of the deployment.
Running containers on AWS requires more networking setup than GCP. ECS Fargate needs a VPC, subnet, internet gateway, and security group before a single container can start. This module handles all of that networking plus the ECS compute resources.
In this step, get ready to:
- Define VPC networking resources for your Fargate tasks.
- Create ECS cluster, task definition, and service resources.
- Add CloudWatch logging and output the public IP discovery command.
Create the module structure and define VPC networking
ECS Fargate tasks run inside a VPC. You need a public subnet with an internet gateway so the container gets a public IP address that responds to HTTP requests.
- Create the AWS ECS module directory and its three files by running these commands:
mkdir -p terraform/modules/aws-ecs
touch terraform/modules/aws-ecs/main.tf
touch terraform/modules/aws-ecs/variables.tf
touch terraform/modules/aws-ecs/outputs.tf
- Confirm the files exist by running:
ls terraform/modules/aws-ecs/
You should see main.tf, outputs.tf, and variables.tf in the output.
- Define the module's input variables by opening terraform/modules/aws-ecs/variables.tf and adding this code:
variable "region" {
description = "AWS region"
type = string
}
variable "app_name" {
description = "Application name"
type = string
}
variable "image_uri" {
description = "Container image URI from ECR"
type = string
}
What do these variables do?
- region tells the module which AWS region to deploy into.
- app_name is used to name all resources consistently (VPC, cluster, service).
- image_uri is the full ECR image URI you pushed in the previous step.
- Save variables.tf.
- Now define the VPC networking resources by opening terraform/modules/aws-ecs/main.tf and adding this code:
data "aws_availability_zones" "available" {
state = "available"
}
resource "aws_vpc" "main" {
cidr_block = "10.0.0.0/16"
enable_dns_hostnames = true
enable_dns_support = true
tags = {
Name = "${var.app_name}-vpc"
}
}
resource "aws_subnet" "public" {
vpc_id = aws_vpc.main.id
cidr_block = "10.0.1.0/24"
availability_zone = data.aws_availability_zones.available.names[0]
map_public_ip_on_launch = true
tags = {
Name = "${var.app_name}-public-subnet"
}
}
resource "aws_internet_gateway" "main" {
vpc_id = aws_vpc.main.id
tags = {
Name = "${var.app_name}-igw"
}
}
resource "aws_route_table" "public" {
vpc_id = aws_vpc.main.id
route {
cidr_block = "0.0.0.0/0"
gateway_id = aws_internet_gateway.main.id
}
tags = {
Name = "${var.app_name}-public-rt"
}
}
resource "aws_route_table_association" "public" {
subnet_id = aws_subnet.public.id
route_table_id = aws_route_table.public.id
}
resource "aws_security_group" "ecs" {
name = "${var.app_name}-ecs-sg"
description = "Allow inbound traffic to ECS tasks"
vpc_id = aws_vpc.main.id
ingress {
from_port = 8080
to_port = 8080
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 = {
Name = "${var.app_name}-ecs-sg"
}
}
What does this networking code do?
- The data block fetches available availability zones in your region so you do not hardcode zone names.
- The VPC gives your ECS tasks an isolated network with a 10.0.0.0/16 CIDR range (65,536 IP addresses).
- The public subnet (10.0.1.0/24) automatically assigns public IPs to resources launched inside it.
- The internet gateway and route table send outbound traffic (0.0.0.0/0) to the public internet.
- The security group allows inbound TCP on port 8080 (your app's port) and permits all outbound traffic.
- Save main.tf.
You won't see a change yet. The next substep adds the ECS resources that use this network.
Seeing syntax errors in your editor?
Make sure every opening brace { has a matching closing brace }. HCL uses braces for both resource blocks and nested blocks like ingress and egress.
help me fix HCL syntax errors in my Terraform AWS VPC module
Define the ECS cluster, task definition, and service
With networking in place, you can define the compute layer. ECS needs an IAM role to pull your container image, a cluster to organize tasks, a task definition describing the container, and a service to keep it running.
- Add the IAM role, ECS cluster, task definition, and service resources below the security group in terraform/modules/aws-ecs/main.tf:
resource "aws_iam_role" "ecs_execution" {
name = "${var.app_name}-ecs-execution-role"
assume_role_policy = jsonencode({
Version = "2012-10-17"
Statement = [
{
Action = "sts:AssumeRole"
Effect = "Allow"
Principal = {
Service = "ecs-tasks.amazonaws.com"
}
}
]
})
}
resource "aws_iam_role_policy_attachment" "ecs_execution" {
role = aws_iam_role.ecs_execution.name
policy_arn = "arn:aws:iam::aws:policy/service-role/AmazonECSTaskExecutionRolePolicy"
}
resource "aws_ecs_cluster" "main" {
name = "${var.app_name}-cluster"
}
resource "aws_ecs_task_definition" "app" {
family = var.app_name
requires_compatibilities = ["FARGATE"]
network_mode = "awsvpc"
cpu = "256"
memory = "512"
execution_role_arn = aws_iam_role.ecs_execution.arn
container_definitions = jsonencode([
{
name = var.app_name
image = var.image_uri
essential = true
portMappings = [
{
containerPort = 8080
protocol = "tcp"
}
]
environment = [
{
name = "CLOUD_PROVIDER"
value = "aws"
}
]
logConfiguration = {
logDriver = "awslogs"
options = {
"awslogs-group" = aws_cloudwatch_log_group.ecs.name
"awslogs-region" = var.region
"awslogs-stream-prefix" = "ecs"
}
}
}
])
}
resource "aws_ecs_service" "app" {
name = "${var.app_name}-service"
cluster = aws_ecs_cluster.main.id
task_definition = aws_ecs_task_definition.app.arn
desired_count = 1
launch_type = "FARGATE"
network_configuration {
subnets = [aws_subnet.public.id]
security_groups = [aws_security_group.ecs.id]
assign_public_ip = true
}
}
What does this ECS code do?
- The IAM role with AmazonECSTaskExecutionRolePolicy lets ECS pull images from ECR and write logs to CloudWatch.
- The task definition sets 0.25 vCPU and 512 MB memory (the smallest Fargate configuration). It passes CLOUD_PROVIDER=aws as an environment variable so your app knows which cloud it is running on.
- The ECS service launches one Fargate task with assign_public_ip = true so you can reach it directly from the internet.
- The logConfiguration references a CloudWatch log group that you will define in the next substep. Terraform resolves dependencies regardless of declaration order in the file.
- Save main.tf.
Your editor may show a warning about the unresolved aws_cloudwatch_log_group.ecs reference. You will fix that next.
JSON encoding errors in container_definitions?
The container_definitions block uses jsonencode() which requires HCL map syntax inside (no trailing commas after the last item in a list or object). Check that each list item uses HCL notation, not raw JSON.
help me fix jsonencode errors in my ECS task definition
Add CloudWatch logging and module outputs
Your task definition references a log group that does not exist yet. You also need outputs so the root module can display connection information after deployment.
- In terraform/modules/aws-ecs/main.tf, find the security group resource that ends with:
tags = {
Name = "${var.app_name}-ecs-sg"
}
}
- Add the CloudWatch log group resource directly below it (before the IAM role):
resource "aws_cloudwatch_log_group" "ecs" {
name = "/ecs/${var.app_name}"
retention_in_days = 7
}
What does this log group do?
CloudWatch Logs captures stdout and stderr from your container. The 7-day retention keeps costs low while giving you enough history to debug issues. The task definition's logConfiguration block points at this log group by referencing aws_cloudwatch_log_group.ecs.name.
- Now define the module outputs by opening terraform/modules/aws-ecs/outputs.tf and adding this code:
output "service_info" {
description = "ECS service information"
value = "Service running in cluster '${aws_ecs_cluster.main.name}'. Get the public IP with: aws ecs list-tasks --cluster ${aws_ecs_cluster.main.name} --query 'taskArns[0]' --output text | xargs -I {} aws ecs describe-tasks --cluster ${aws_ecs_cluster.main.name} --tasks {} --query 'tasks[0].attachments[0].details[?name==`networkInterfaceId`].value' --output text | xargs -I {} aws ec2 describe-network-interfaces --network-interface-ids {} --query 'NetworkInterfaces[0].Association.PublicIp' --output text"
}
output "cluster_name" {
description = "ECS cluster name"
value = aws_ecs_cluster.main.name
}
Why such a long command in the output?
ECS Fargate does not expose the task's public IP directly in Terraform outputs. You need to chain three AWS CLI calls: find the running task, get its network interface ID, then look up the public IP from that interface. The output prints this full command so you can copy it after deployment.
- Save both main.tf and outputs.tf.
You now have a complete AWS ECS Fargate module with networking, compute, logging, and outputs.
Editor still showing unresolved references?
Make sure aws_cloudwatch_log_group "ecs" sits between the security group and the IAM role in main.tf. Terraform resolves references across the file regardless of order, but your editor's linter may not. If warnings persist, they will clear when you run terraform init in a later step.
help me fix unresolved Terraform references in my ECS module
✔️ Awesome, I've got everything!
Great. Double check you have saved all three files in terraform/modules/aws-ecs/.
ⓧ I'd like to double check the full code
Here is the complete code for each file in the AWS ECS module.
terraform/modules/aws-ecs/variables.tf
variable "region" {
description = "AWS region"
type = string
}
variable "app_name" {
description = "Application name"
type = string
}
variable "image_uri" {
description = "Container image URI from ECR"
type = string
}
terraform/modules/aws-ecs/main.tf
data "aws_availability_zones" "available" {
state = "available"
}
resource "aws_vpc" "main" {
cidr_block = "10.0.0.0/16"
enable_dns_hostnames = true
enable_dns_support = true
tags = {
Name = "${var.app_name}-vpc"
}
}
resource "aws_subnet" "public" {
vpc_id = aws_vpc.main.id
cidr_block = "10.0.1.0/24"
availability_zone = data.aws_availability_zones.available.names[0]
map_public_ip_on_launch = true
tags = {
Name = "${var.app_name}-public-subnet"
}
}
resource "aws_internet_gateway" "main" {
vpc_id = aws_vpc.main.id
tags = {
Name = "${var.app_name}-igw"
}
}
resource "aws_route_table" "public" {
vpc_id = aws_vpc.main.id
route {
cidr_block = "0.0.0.0/0"
gateway_id = aws_internet_gateway.main.id
}
tags = {
Name = "${var.app_name}-public-rt"
}
}
resource "aws_route_table_association" "public" {
subnet_id = aws_subnet.public.id
route_table_id = aws_route_table.public.id
}
resource "aws_security_group" "ecs" {
name = "${var.app_name}-ecs-sg"
description = "Allow inbound traffic to ECS tasks"
vpc_id = aws_vpc.main.id
ingress {
from_port = 8080
to_port = 8080
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 = {
Name = "${var.app_name}-ecs-sg"
}
}
resource "aws_cloudwatch_log_group" "ecs" {
name = "/ecs/${var.app_name}"
retention_in_days = 7
}
resource "aws_iam_role" "ecs_execution" {
name = "${var.app_name}-ecs-execution-role"
assume_role_policy = jsonencode({
Version = "2012-10-17"
Statement = [
{
Action = "sts:AssumeRole"
Effect = "Allow"
Principal = {
Service = "ecs-tasks.amazonaws.com"
}
}
]
})
}
resource "aws_iam_role_policy_attachment" "ecs_execution" {
role = aws_iam_role.ecs_execution.name
policy_arn = "arn:aws:iam::aws:policy/service-role/AmazonECSTaskExecutionRolePolicy"
}
resource "aws_ecs_cluster" "main" {
name = "${var.app_name}-cluster"
}
resource "aws_ecs_task_definition" "app" {
family = var.app_name
requires_compatibilities = ["FARGATE"]
network_mode = "awsvpc"
cpu = "256"
memory = "512"
execution_role_arn = aws_iam_role.ecs_execution.arn
container_definitions = jsonencode([
{
name = var.app_name
image = var.image_uri
essential = true
portMappings = [
{
containerPort = 8080
protocol = "tcp"
}
]
environment = [
{
name = "CLOUD_PROVIDER"
value = "aws"
}
]
logConfiguration = {
logDriver = "awslogs"
options = {
"awslogs-group" = aws_cloudwatch_log_group.ecs.name
"awslogs-region" = var.region
"awslogs-stream-prefix" = "ecs"
}
}
}
])
}
resource "aws_ecs_service" "app" {
name = "${var.app_name}-service"
cluster = aws_ecs_cluster.main.id
task_definition = aws_ecs_task_definition.app.arn
desired_count = 1
launch_type = "FARGATE"
network_configuration {
subnets = [aws_subnet.public.id]
security_groups = [aws_security_group.ecs.id]
assign_public_ip = true
}
}
terraform/modules/aws-ecs/outputs.tf
output "service_info" {
description = "ECS service information"
value = "Service running in cluster '${aws_ecs_cluster.main.name}'. Get the public IP with: aws ecs list-tasks --cluster ${aws_ecs_cluster.main.name} --query 'taskArns[0]' --output text | xargs -I {} aws ecs describe-tasks --cluster ${aws_ecs_cluster.main.name} --tasks {} --query 'tasks[0].attachments[0].details[?name==`networkInterfaceId`].value' --output text | xargs -I {} aws ec2 describe-network-interfaces --network-interface-ids {} --query 'NetworkInterfaces[0].Association.PublicIp' --output text"
}
output "cluster_name" {
description = "ECS cluster name"
value = aws_ecs_cluster.main.name
}
Why all this networking for one container?
GCP Cloud Run handles networking automatically behind the scenes. AWS ECS Fargate gives you explicit control over VPC configuration. This means more code upfront but also more flexibility for production scenarios like private subnets, NAT gateways, or multiple availability zones.
The Secret Mission at the end of this project takes advantage of this flexibility by adding an Application Load Balancer in front of your service.
Both cloud modules are ready. Next up, you will wire them together with a root Terraform configuration that deploys to both clouds in a single command.
Wire Up the Root Terraform Configuration
You've built two powerful Terraform modules that know how to deploy containers to AWS and GCP. Now you need a way to orchestrate them both from a single entry point.
The root Terraform configuration is the conductor that tells each module what to build and where. In this step, you'll create the files that wire your modules together with provider credentials, shared variables, and unified outputs.
In this step, get ready to:
- Configure provider versions and authentication for both AWS and GCP.
- Create the root module that calls both cloud-specific modules.
- Define outputs to display both service URLs after deployment.
Create the versions and provider configuration
Terraform needs to know which cloud providers to use and which plugin versions to download. The versions.tf file locks down compatible plugin versions so your configuration stays reproducible.
- Create a new file called versions.tf inside your terraform/ directory and add the following code:
terraform {
required_version = ">= 1.5.0"
required_providers {
aws = {
source = "hashicorp/aws"
version = "~> 5.0"
}
google = {
source = "hashicorp/google"
version = "~> 6.0"
}
}
}
What does this code do?
The terraform block sets two constraints.
- required_version = ">= 1.5.0" ensures anyone running this config has Terraform 1.5.0 or newer.
- The required_providers block pins the AWS provider to the 5.x line and the Google provider to the 6.x line, preventing breaking changes from sneaking in.
- Save versions.tf.
Next, you need to tell each provider how to authenticate. The providers.tf file configures the AWS provider to use your CLI credentials and the Google provider to use the application-default credentials you set up earlier.
- Create a new file called providers.tf inside your terraform/ directory and add the following code:
provider "aws" {
region = var.aws_region
}
provider "google" {
project = var.gcp_project_id
region = var.gcp_region
}
What does this code do?
- The aws provider reads your region from a variable. It automatically uses the credentials you configured with aws configure in Step 1.
- The google provider needs both a project ID and region. It uses the application-default credentials from your earlier gcloud auth application-default login.
- Save providers.tf.
You won't see a change yet. The next files define the variables these providers reference.
Create the root module, variables, and tfvars
The root main.tf is where you call both modules and pass them the information they need. The variables.tf declares each input, and terraform.tfvars supplies the actual values.
- Create variables.tf inside your terraform/ directory and add the following code:
variable "aws_region" {
description = "AWS region for deployment"
type = string
default = "us-east-1"
}
variable "gcp_project_id" {
description = "GCP project ID"
type = string
}
variable "gcp_region" {
description = "GCP region for deployment"
type = string
default = "us-central1"
}
variable "app_name" {
description = "Application name used for resource naming"
type = string
default = "multicloud-app"
}
variable "ecr_image_uri" {
description = "Full ECR image URI including tag"
type = string
}
variable "artifact_registry_image_uri" {
description = "Full Artifact Registry image URI including tag"
type = string
}
What does this code do?
Each variable block declares an input your configuration needs.
- Variables with a default value (aws_region, gcp_region, app_name) are optional to override.
- Variables without a default (gcp_project_id, ecr_image_uri, artifact_registry_image_uri) are required. Terraform will refuse to run without them.
- Save variables.tf.
Now create the root module that invokes both cloud-specific modules.
- Create main.tf inside your terraform/ directory and add the following code:
module "gcp_cloudrun" {
source = "./modules/gcp-cloudrun"
project_id = var.gcp_project_id
region = var.gcp_region
app_name = var.app_name
image_uri = var.artifact_registry_image_uri
}
module "aws_ecs" {
source = "./modules/aws-ecs"
region = var.aws_region
app_name = var.app_name
image_uri = var.ecr_image_uri
}
What does this code do?
Each module block points to a local module directory via source and passes in the variables that module expects.
- gcp_cloudrun receives the Artifact Registry image URI and deploys to Cloud Run.
- aws_ecs receives the ECR image URI and deploys to ECS Fargate.
This is the power of modular Terraform. One terraform apply from this root will deploy to both clouds simultaneously.
- Save main.tf.
Finally, supply the actual values for your required variables. Terraform automatically loads any file named terraform.tfvars in the working directory.
- Create terraform.tfvars inside your terraform/ directory and add the following, replacing the placeholder values with your own:
gcp_project_id = "[[GCP_PROJECT_ID="your-gcp-project-id"]]"
aws_region = "us-east-1"
gcp_region = "us-central1"
app_name = "multicloud-app"
ecr_image_uri = "[[ECR_IMAGE_URI="123456789012.dkr.ecr.us-east-1.amazonaws.com/multicloud-app:latest"]]"
artifact_registry_image_uri = "[[ARTIFACT_REGISTRY_IMAGE_URI="us-central1-docker.pkg.dev/your-project-id/multicloud-app/multicloud-app:latest"]]"
Where do I find these values?
- gcp_project_id is your GCP project ID from the Google Cloud Console (the short string, not the display name).
- ecr_image_uri is the full URI you used when pushing to ECR in Step 3. It includes your AWS account ID.
- artifact_registry_image_uri is the full URI you used when pushing to Artifact Registry in Step 3. It includes your GCP project ID.
- Save terraform.tfvars.
Not sure what your image URIs are?
You can retrieve your ECR image URI by running aws ecr describe-repositories --repository-names multicloud-app and checking the repositoryUri field. Append :latest to get the full image URI.
For Artifact Registry, run gcloud artifacts docker images list us-central1-docker.pkg.dev/YOUR_PROJECT_ID/multicloud-app to see your pushed images.
Help me find my container image URIs for ECR and Artifact Registry.
Create the outputs file
After Terraform finishes deploying, you'll want to see the URLs for both services immediately in your terminal. Outputs surface values from your modules to the root level.
- Create outputs.tf inside your terraform/ directory and add the following code:
output "gcp_service_url" {
description = "GCP Cloud Run service URL"
value = module.gcp_cloudrun.service_url
}
output "aws_service_info" {
description = "AWS ECS service details"
value = module.aws_ecs.service_info
}
What does this code do?
- gcp_service_url pulls the Cloud Run URL from the GCP module. You'll be able to visit this URL to see your app running on GCP.
- aws_service_info pulls the ECS service details from the AWS module, including a command to discover the public IP of your Fargate task.
- Save outputs.tf.
- Confirm all your new root files exist by running:
ls terraform/*.tf
You should see main.tf, outputs.tf, providers.tf, terraform.tfvars, variables.tf, and versions.tf listed alongside the modules/ directory.
Missing a file?
Make sure you created each file inside the terraform/ directory and not in the project root or inside a module folder. The root-level .tf files live alongside the modules/ directory, not inside it.
Help me check my Terraform directory structure is correct.
✔️ Awesome, I've got everything!
Great. Double check you've saved all six files in your terraform/ directory.
ⓧ I'd like to double check the full code
Here are all six root Terraform files for comparison.
terraform {
required_version = ">= 1.5.0"
required_providers {
aws = {
source = "hashicorp/aws"
version = "~> 5.0"
}
google = {
source = "hashicorp/google"
version = "~> 6.0"
}
}
}
provider "aws" {
region = var.aws_region
}
provider "google" {
project = var.gcp_project_id
region = var.gcp_region
}
variable "aws_region" {
description = "AWS region for deployment"
type = string
default = "us-east-1"
}
variable "gcp_project_id" {
description = "GCP project ID"
type = string
}
variable "gcp_region" {
description = "GCP region for deployment"
type = string
default = "us-central1"
}
variable "app_name" {
description = "Application name used for resource naming"
type = string
default = "multicloud-app"
}
variable "ecr_image_uri" {
description = "Full ECR image URI including tag"
type = string
}
variable "artifact_registry_image_uri" {
description = "Full Artifact Registry image URI including tag"
type = string
}
module "gcp_cloudrun" {
source = "./modules/gcp-cloudrun"
project_id = var.gcp_project_id
region = var.gcp_region
app_name = var.app_name
image_uri = var.artifact_registry_image_uri
}
module "aws_ecs" {
source = "./modules/aws-ecs"
region = var.aws_region
app_name = var.app_name
image_uri = var.ecr_image_uri
}
output "gcp_service_url" {
description = "GCP Cloud Run service URL"
value = module.gcp_cloudrun.service_url
}
output "aws_service_info" {
description = "AWS ECS service details"
value = module.aws_ecs.service_info
}
gcp_project_id = "your-gcp-project-id"
aws_region = "us-east-1"
gcp_region = "us-central1"
app_name = "multicloud-app"
ecr_image_uri = "123456789012.dkr.ecr.us-east-1.amazonaws.com/multicloud-app:latest"
artifact_registry_image_uri = "us-central1-docker.pkg.dev/your-project-id/multicloud-app/multicloud-app:latest"
Your entire Terraform configuration is wired up and ready to deploy. Next up, you'll run terraform init and terraform apply to bring both cloud services to life from a single command.
Deploy and Verify
Your entire Terraform codebase is written and your container images are waiting in both registries.
Now it's time to bring everything to life. In this step, you'll run Terraform's init-plan-apply workflow to provision resources across both clouds simultaneously, then verify that the same app is serving requests from AWS and GCP.
In this step, get ready to:
- Initialize Terraform and review the execution plan.
- Apply the configuration to deploy resources on both clouds.
- Test both live endpoints with curl.
Initialize and review the plan
Before Terraform can create any resources, it needs to download the provider plugins for AWS and GCP. The terraform init command handles this setup.
- Navigate into the terraform/ directory by running this command:
cd terraform
- Initialize Terraform by running:
terraform init
What does this command do?
The terraform init command reads your versions.tf file and downloads the hashicorp/aws and hashicorp/google provider plugins. It also sets up the .terraform/ directory where Terraform stores its working data.
- You should see output ending with a message that says Terraform has been successfully initialized.
- Preview the resources Terraform will create by running:
terraform plan
What does this command do?
The terraform plan command compares your desired state (the .tf files) against what currently exists (nothing yet) and produces a list of resources it needs to create.
You should see a plan showing roughly 12-15 resources to add across both clouds. Look for AWS resources like aws_vpc, aws_ecs_cluster, and aws_ecs_service, alongside GCP resources like google_cloud_run_v2_service and google_cloud_run_v2_service_iam_member.
- Confirm you see Plan: X to add, 0 to change, 0 to destroy at the bottom of the output. The number of resources to add should be greater than zero with nothing to change or destroy.
Seeing errors in the plan?
- If you see a provider authentication error for AWS, run aws sts get-caller-identity to confirm your credentials are active.
- If you see a GCP authentication error, run gcloud auth application-default login again to refresh your credentials.
- If Terraform cannot find a variable value, double-check that your terraform.tfvars file exists and has all six variables populated.
Help me debug my terraform plan errors
Apply the configuration
The plan looks good. Time to provision real cloud resources on both AWS and GCP with a single command.
- Deploy all resources by running:
terraform apply
What happens during apply?
Terraform will display the same plan you just reviewed and prompt you with a confirmation. Type yes and press Enter to proceed.
Terraform creates resources in parallel where possible. The GCP Cloud Run service typically deploys in under a minute. The AWS ECS Fargate resources (VPC, subnets, security groups, cluster, task, and service) take 2-3 minutes total.
- When prompted, type yes and press Enter.
- Wait for the apply to complete. You should see output ending with Apply complete! followed by your outputs.
After the apply finishes, Terraform prints two outputs. The gcp_service_url output shows your Cloud Run URL directly. The aws_service_info output shows a CLI command you'll use to find the ECS task's public IP.
Apply failed partway through?
- If the GCP module fails with a permissions error, confirm that run.googleapis.com and artifactregistry.googleapis.com APIs are enabled in your project.
- If the AWS module fails on the IAM role, your AWS credentials may not have sufficient permissions. Ensure your IAM user has at least AmazonECS_FullAccess, AmazonVPCFullAccess, and IAMFullAccess policies attached.
- You can safely re-run terraform apply after fixing the issue. Terraform picks up where it left off.
Help me fix my terraform apply error
Test both endpoints
Both services are now deployed. The real proof is making HTTP requests to each endpoint and seeing the same app respond from different clouds.
- Test the GCP Cloud Run endpoint by running this command (replace the URL with the gcp_service_url from your Terraform output):
curl [[GCP_SERVICE_URL="your Cloud Run URL from terraform output"]]
- You should see this JSON response:
{"cloud": "gcp", "message": "Hello from gcp!", "service": "multicloud-app"}
What does this response tell you?
The CLOUD_PROVIDER environment variable you set in the Cloud Run module is being read by the Flask app. This confirms the container image from Artifact Registry is running correctly on GCP.
Now you need the AWS endpoint. The ECS Fargate task has a public IP assigned, but you need to look it up using the AWS CLI.
- Get the public IP of your ECS task by running this command:
aws ecs list-tasks --cluster multicloud-app-cluster --query 'taskArns[0]' --output text | xargs -I {} aws ecs describe-tasks --cluster multicloud-app-cluster --tasks {} --query 'tasks[0].attachments[0].details[?name==`networkInterfaceId`].value' --output text | xargs -I {} aws ec2 describe-network-interfaces --network-interface-ids {} --query 'NetworkInterfaces[0].Association.PublicIp' --output text
What does this command do?
- It first lists the running task in your multicloud-app-cluster ECS cluster.
- It then describes that task to find the network interface attached to it.
- Finally, it queries the network interface to get the public IP address.
The output is a single IP address like 3.87.xxx.xxx.
Wait for the task to start
If you get an empty result, the ECS task may still be starting. Wait 30-60 seconds and try again. The Fargate task needs time to pull the container image from ECR and begin running.
- Test the AWS ECS endpoint by running this command (replace the IP with the one you just retrieved):
curl http://[[ECS_PUBLIC_IP="your ECS task public IP"]]:8080
- You should see this JSON response:
{"cloud": "aws", "message": "Hello from aws!", "service": "multicloud-app"}
Both endpoints return the same app structure, with only the cloud field differing. This proves the same container image is running on both clouds, deployed from a single Terraform workflow.
Can't reach the AWS endpoint?
- Make sure you include :8080 at the end of the URL. The ECS task listens on port 8080, not port 80.
- If the connection times out, check that the security group allows inbound traffic on port 8080. Run terraform plan to verify no drift occurred.
- If you get a connection refused error, the task may not be fully healthy yet. Wait another minute and retry.
Help me troubleshoot why I can't reach my ECS Fargate service
You just deployed the same container to two different cloud providers with a single terraform apply. Next up, take on the Secret Mission to add production-grade load balancing to your AWS service.
Secret mission
Add an Application Load Balancer
Your ECS Fargate service works, but it's exposed directly via a task public IP. In production, traffic flows through a load balancer that handles health checks, distributes requests, and gives you a stable DNS endpoint. Add an Application Load Balancer to your AWS module for production-grade traffic management.
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. Your AWS Fargate service costs approximately $0.01/hr while running. Your GCP Cloud Run service stays within the free tier for this project.
Cost warning
Your ECS Fargate task and ALB incur charges while running (under $1/day total). GCP Cloud Run's free tier covers the usage from this project. If you are not planning to continue building on this project, choose Pause or Delete to avoid ongoing AWS charges.
Resources you used:
- AWS: ECR repository, VPC, subnets, internet gateway, route table, Application Load Balancer, target group, security groups, ECS cluster, Fargate task/service, IAM execution role, CloudWatch log group.
- GCP: Artifact Registry repository, Cloud Run service.
- Local: Project directory (multicloud-app) with app/ and terraform/ folders, local Docker images.
Keep everything running
No action needed. Choose this if you are still actively building or experimenting with your multi-cloud deployment.
- AWS resources will continue running at approximately $0.01/hr (under $1/day).
- GCP Cloud Run scales to zero when idle. No charges accrue within the free tier.
- Local project files remain on your Desktop.
Pause - I'll come back to this later
Serverless resources like ECS Fargate and Cloud Run cannot be paused. The best approach is to tear down and redeploy later using your Terraform code.
- Destroy all Terraform-managed cloud resources by running this command from your terraform/ directory:
cd ~/Desktop/multicloud-app/terraform
terraform destroy
- Type yes when prompted to confirm destruction.
- Keep your local project files intact so you can redeploy with terraform apply later.
- Your ECR and Artifact Registry repositories remain (they were created outside of Terraform). They do not incur charges for storage of a single small image.
Delete - I don't want to use this again
Remove all cloud resources and local project files completely.
- Destroy all Terraform-managed resources by running:
cd ~/Desktop/multicloud-app/terraform
terraform destroy
- Type yes when prompted to confirm.
- Delete the ECR repository and its images by running:
aws ecr delete-repository --repository-name multicloud-app --region us-east-1 --force
- Delete the GCP Artifact Registry repository by running:
gcloud artifacts repositories delete multicloud-app --location=us-central1
- Confirm the deletion when prompted by typing Y.
- Remove local Docker images by running:
docker rmi multicloud-app
- Delete the local project folder by running:
rm -rf ~/Desktop/multicloud-app
That's a Wrap!
That's a Wrap!
Nice work! You just deployed the same containerized application across two cloud providers using a single Terraform workflow.
You've learned how to:
- Built a containerized Python Flask application with Docker and pushed images to both AWS ECR and GCP Artifact Registry.
- Wrote modular Terraform with separate cloud-specific modules and a shared root configuration managing both providers.
- Deployed to AWS ECS Fargate with full VPC networking and to GCP Cloud Run with automatic scaling, all from a single terraform apply.
- Secret Mission: Added an Application Load Balancer for production-grade traffic routing on AWS.
Ready to quiz yourself?