# GIT # GITHUB 👁️ 20 views ⏱️ 26 min read

Modern CI/CD Tutorial: Automating Multi-Cloud Deployment with Terraform and GitHub Actions

Ever found application deployment complex and error-prone? This tutorial guides you through building a modern CI/CD system that automates the deployment of a simple Flask application to AWS EC2 and Google Cloud Run simultaneously.

Tutorial CI/CD Modern
Flow CI/CD Modern
Modern CI/CD. In this tutorial, you'll learn how to integrate Terraform for Infrastructure as Code (IaC) management and GitHub Actions to automate the entire deployment lifecycle of a simple Flask application to both AWS EC2 and Google Cloud Run simultaneously. You'll build a simple REST API to manage a book list, then configure the necessary cloud infrastructure using Terraform, and finally create a CI/CD pipeline that will automatically build, test, and deploy your application every time there's a code change. This isn't just theory; you'll have a fully functional multi-cloud deployment system by the end of this tutorial. What you'll learn:
  • Create a simple Flask REST API application.
  • Use Terraform to provision infrastructure on AWS EC2 and Google Cloud Run.
  • Securely configure cloud credentials in GitHub Secrets.
  • Build an automated CI/CD pipeline using GitHub Actions.
  • Deploy a Dockerized application to AWS EC2 and Google Cloud Run simultaneously.
  • Test and verify your multi-cloud deployment.

What Are CI/CD, Terraform, and GitHub Actions?

CI/CD (Continuous Integration/Continuous Deployment) is a set of practices for automatically integrating code changes from multiple developers (Continuous Integration) and then automatically deploying the application to production environments (Continuous Deployment). A simple analogy: imagine you have a new recipe. CI is the process where all chefs (developers) combine their ingredients (code) into one main kitchen daily, ensuring everything fits together and doesn't spoil the dish. CD is the automated process where the finished, tested dish is immediately served to customers (production) without manual intervention. Terraform is an Infrastructure as Code (IaC) tool that allows you to define and provision cloud and on-premise infrastructure resources using declarative configuration files. With Terraform, you can manage servers, databases, networks, and other services as code. Think of Terraform as an architect creating a blueprint (`.tf` files) for a building (cloud infrastructure). You simply define what you want in that blueprint, and Terraform will automatically build it according to specifications, even across multiple locations (multi-cloud) consistently. GitHub Actions is an automation feature integrated directly into GitHub, allowing you to create workflows to automate software development tasks, including CI/CD. This means you can run tests, build applications, and deploy to servers directly from your GitHub repository. GitHub Actions acts like a personal assistant working 24/7. Every time you make changes to your code and push them to GitHub, this assistant will automatically perform a series of tasks you've instructed, from checking the code and building the application to deploying it to your prepared servers.

Prerequisites

Before we begin, ensure you have the following prerequisites installed on your system:
  • Python 3.9+: For our Flask application.
  • Git: For source code management and interaction with GitHub.
  • Docker Desktop: For building and running Docker containers locally.
  • AWS CLI: For configuring AWS credentials.
  • gcloud CLI: For configuring Google Cloud credentials.
  • Terraform CLI 1.3+: For managing infrastructure.
  • GitHub Account.
  • AWS Account (with admin access or at least access for EC2, ECR, IAM).
  • Google Cloud Account (with admin access or at least access for Cloud Run, GCR/Artifact Registry, IAM).
Here are basic installation commands. Adjust them according to your operating system:
# Python installation (example for Ubuntu/Debian)
sudo apt update
sudo apt install python3 python3-pip

# Git installation
sudo apt install git

# Docker Desktop installation (follow official Docker guide for your OS)
# https://docs.docker.com/desktop/install/

# AWS CLI installation (follow official AWS guide)
# curl "https://awscli.amazonaws.com/awscli-exe-linux-x86_64.zip" -o "awscliv2.zip"
# unzip awscliv2.zip
# sudo ./aws/install

# gcloud CLI installation (follow official Google Cloud guide)
# curl https://sdk.cloud.google.com | bash
# exec -l $SHELL
# gcloud init

# Terraform installation (follow official HashiCorp guide)
# wget -O- https://apt.releases.hashicorp.com/gpg | sudo gpg --dearmor -o /usr/share/keyrings/hashicorp-archive-keyring.gpg
# echo "deb [signed-by=/usr/share/keyrings/hashicorp-archive-keyring.gpg] https://apt.releases.hashicorp.com $(lsb_release -cs) main" | sudo tee /etc/apt/sources.list.d/hashicorp.list
# sudo apt update && sudo apt install terraform

# Verify installation
python3 --version
git --version
docker --version
aws --version
gcloud --version
terraform --version
💡 Tip: Ensure all CLI tools are installed and accessible from your terminal. If you encounter any issues, check the official documentation for each tool.

Step 1: Create a Simple Flask Application

In this step, we'll create a simple Flask REST API application to manage a list of books. This application will run inside a Docker container. Create a new directory for your project, then initialize a Git repository and create some files:
mkdir multi-cloud-ci-cd
cd multi-cloud-ci-cd
git init
touch app.py Dockerfile requirements.txt
Create file app.py:
# app.py
from flask import Flask, jsonify, request

app = Flask(__name__)

# Data buku sementara (in-memory)
books = [
    {"id": 1, "title": "The Hitchhiker's Guide to the Galaxy", "author": "Douglas Adams"},
    {"id": 2, "title": "Pride and Prejudice", "author": "Jane Austen"}
]
next_id = 3

@app.route('/books', methods=['GET'])
def get_books():
    return jsonify(books)

@app.route('/books/<int:book_id>', methods=['GET'])
def get_book(book_id):
    book = next((b for b in books if b['id'] == book_id), None)
    if book:
        return jsonify(book)
    return jsonify({"message": "Book not found"}), 404

@app.route('/books', methods=['POST'])
def add_book():
    global next_id
    new_book = {
        "id": next_id,
        "title": request.json.get('title'),
        "author": request.json.get('author')
    }
    if not new_book['title'] or not new_book['author']:
        return jsonify({"message": "Title and author are required"}), 400
    books.append(new_book)
    next_id += 1
    return jsonify(new_book), 201

if __name__ == '__main__':
    app.run(host='0.0.0.0', port=5000)
  • The code above is a simple Flask API with three endpoints: getting all books, getting a book by ID, and adding a new book.
  • Book data is stored in memory (`books`), so it will be lost every time the application restarts. This is intentional to focus on CI/CD, not data persistence.
  • The application will run on port 5000 and listen on all network interfaces (`0.0.0.0`).
Create file requirements.txt:
Flask==2.3.2
  • This file defines the Python dependencies required by our Flask application.
Create file Dockerfile:
# Dockerfile
# Use a lightweight Python base image
FROM python:3.9-slim-buster

# Set the working directory inside the container
WORKDIR /app

# Copy requirements.txt and install dependencies
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt

# Copy all application files to the working directory
COPY . .

# Expose the port used by the Flask application
EXPOSE 5000

# Command to run the application when the container starts
CMD ["python", "app.py"]
  • This Dockerfile defines how to build the Docker image for our Flask application.
  • It uses `python:3.9-slim-buster` as the base image for a smaller size.
  • It installs dependencies, copies the code, and exposes port 5000.
Now, let's build and run the Docker container locally to ensure the application works:
docker build -t flask-book-api .
docker run -p 5000:5000 --name my-book-app flask-book-api
Open a new terminal and test the API:
# Get all books
curl http://localhost:5000/books
Expected output:
[
  {
    "id": 1,
    "title": "The Hitchhiker's Guide to the Galaxy",
    "author": "Douglas Adams"
  },
  {
    "id": 2,
    "title": "Pride and Prejudice",
    "author": "Jane Austen"
  }
]
# Add a new book
curl -X POST -H "Content-Type: application/json" -d '{"title": "The Lord of the Rings", "author": "J.R.R. Tolkien"}' http://localhost:5000/books
Expected output:
{
  "author": "J.R.R. Tolkien",
  "id": 3,
  "title": "The Lord of the Rings"
}
If everything works correctly, stop the Docker container (`docker stop my-book-app` and `docker rm my-book-app`).

Step 2: Set Up Cloud Accounts and Credentials

To allow Terraform and GitHub Actions to interact with AWS and Google Cloud, we need to set up the appropriate credentials.

AWS Credentials

We will create a new IAM user with programmatic access and attach the necessary policies.
  1. Log in to the AWS Management Console.
  2. Navigate to the IAM service, then select "Users" and click "Add users".
  3. Provide a user name (e.g., `github-actions-user`).
  4. Select "Access key - Programmatic access" as the credential type.
  5. In the "Permissions" step, choose "Attach existing policies directly".
  6. Search for and select the following policies (for tutorial purposes, we'll grant more permissions, but in production, use the principle of least privilege):
    • `AmazonEC2FullAccess`
    • `AmazonECRFullAccess`
    • `IAMFullAccess` (for creating instance profiles, etc. If you already have one, this can be reduced)
    • `AmazonSSMManagedInstanceCore` (for later if you want to use AWS SSM for command execution)
  7. Complete user creation. Record the `Access key ID` and `Secret access key`. These are crucial and displayed only once.
After that, configure your AWS CLI locally to test the credentials:
aws configure
# Enter the Access Key ID and Secret Access Key you obtained.
# Default region name: ap-southeast-1 (or your chosen region)
# Default output format: json

Google Cloud Credentials

We will create a Service Account and download its JSON key.
  1. Log in to the Google Cloud Console.
  2. Select the project you will use (or create a new one).
  3. Navigate to "IAM & Admin" > "Service Accounts".
  4. Click "CREATE SERVICE ACCOUNT".
  5. Provide a name (e.g., `github-actions-service-account`).
  6. In the "Grant this service account access to project" step, grant the following roles (again, for the tutorial, but in production, use least privilege):
    • `Cloud Run Admin`
    • `Storage Admin` (for GCR/Artifact Registry)
    • `Service Account User` (to allow Cloud Run runtime identity)
  7. Click "Done".
  8. Return to the Service Accounts list, click on the Service Account you just created.
  9. Go to the "Keys" tab, click "ADD KEY" > "Create new key".
  10. Select "JSON" as the key type and click "CREATE". The JSON file will be downloaded. Store this file securely.
To test gcloud credentials locally:
# Log in with your Google Cloud account
gcloud auth login

# Set default project
gcloud config set project <YOUR_GCP_PROJECT_ID>

# Activate the newly created Service Account
gcloud auth activate-service-account --key-file=<PATH_TO_YOUR_SERVICE_ACCOUNT_JSON>

# Set application default credentials
gcloud auth application-default login
⚠️ Attention: Your cloud credentials are highly sensitive. Never store them directly in your Git repository. We will use GitHub Secrets later.

Step 3: Develop Multi-Cloud Infrastructure with Terraform

Now, we will use Terraform to define and provision the necessary infrastructure on AWS and Google Cloud. Create a `terraform` folder in your project root.
mkdir terraform
cd terraform
touch main.tf variables.tf outputs.tf
Create file terraform/variables.tf:
# variables.tf
variable "aws_region" {
  description = "AWS region"
  type        = string
  default     = "ap-southeast-1" # Replace with your preferred AWS region
}

variable "gcp_project_id" {
  description = "Google Cloud Project ID"
  type        = string
  # default = "your-gcp-project-id" # Uncomment and fill if you want a default
}

variable "gcp_region" {
  description = "Google Cloud region for Cloud Run"
  type        = string
  default     = "asia-southeast1" # Replace with your preferred GCP region
}

variable "app_name" {
  description = "Nama aplikasi"
  type        = string
  default     = "flask-book-api"
}

variable "aws_instance_type" {
  description = "Tipe instance EC2"
  type        = string
  default     = "t2.micro"
}
Create file terraform/main.tf:
# main.tf
# AWS Provider Configuration
provider "aws" {
  region = var.aws_region
}

# Google Cloud Provider Configuration
provider "google" {
  project = var.gcp_project_id
  region  = var.gcp_region
}

# --- AWS Resources ---

# ECR Repository for Docker images
resource "aws_ecr_repository" "app_repo" {
  name = var.app_name
  image_tag_mutability = "MUTABLE" # Allow tags to be mutable

  image_scanning_configuration {
    scan_on_push = true
  }
}

# Security Group for EC2 (allow SSH and HTTP)
resource "aws_security_group" "app_sg" {
  name        = "${var.app_name}-sg"
  description = "Allow HTTP and SSH inbound traffic"
  vpc_id      = data.aws_vpc.default.id # Using the default VPC

  ingress {
    description = "HTTP from anywhere"
    from_port   = 80
    to_port     = 80
    protocol    = "tcp"
    cidr_blocks = ["0.0.0.0/0"]
  }

  ingress {
    description = "HTTP from anywhere (port 5000)"
    from_port   = 5000 # Flask application runs on port 5000
    to_port     = 5000
    protocol    = "tcp"
    cidr_blocks = ["0.0.0.0/0"]
  }

  ingress {
    description = "SSH from anywhere"
    from_port   = 22
    to_port     = 22
    protocol    = "tcp"
    cidr_blocks = ["0.0.0.0/0"] # Be cautious in production, restrict IPs
  }

  egress {
    from_port   = 0
    to_port     = 0
    protocol    = "-1"
    cidr_blocks = ["0.0.0.0/0"]
  }

  tags = {
    Name = "${var.app_name}-sg"
  }
}

# Data Source to get the default VPC
data "aws_vpc" "default" {
  default = true
}

# Data Source to get the latest Amazon Linux 2 AMI
data "aws_ami" "amazon_linux_2" {
  most_recent = true
  owners      = ["amazon"]

  filter {
    name   = "name"
    values = ["amzn2-ami-hvm-*-x86_64-gp2"]
  }

  filter {
    name   = "virtualization-type"
    values = ["hvm"]
  }
}

# Key Pair for SSH to EC2 (ensure you have one or create a new one)
resource "aws_key_pair" "deployer_key" {
  key_name   = "${var.app_name}-key"
  # Replace with the path to your SSH public key
  public_key = file("~/.ssh/id_rsa.pub") # Adjust your public key path!
}

# EC2 Instance
resource "aws_instance" "app_server" {
  ami           = data.aws_ami.amazon_linux_2.id
  instance_type = var.aws_instance_type
  key_name      = aws_key_pair.deployer_key.key_name
  vpc_security_group_ids = [aws_security_group.app_sg.id]
  associate_public_ip_address = true # For public access

  # User data to install and run Docker
  user_data = <<-EOF
              #!/bin/bash
              sudo yum update -y
              sudo yum install -y docker
              sudo systemctl start docker
              sudo systemctl enable docker
              sudo usermod -aG docker ec2-user
              echo "alias docker-login='sudo docker login --username AWS -p $(aws ecr get-login-password --region ${var.aws_region}) ${aws_ecr_repository.app_repo.repository_url}'" >> /home/ec2-user/.bashrc
              # Placeholder for application deployment, will be handled by GitHub Actions
              EOF

  tags = {
    Name = "${var.app_name}-ec2"
  }
}

# --- Google Cloud Resources ---

# Google Container Registry (GCR) or Artifact Registry
# GCR is usually created automatically on image push, but we can ensure it
# Cloud Run will use GCR/Artifact Registry by default.

# Service Account for Cloud Run
resource "google_service_account" "cloud_run_sa" {
  account_id   = "${var.app_name}-cloud-run-sa"
  display_name = "Service Account for Cloud Run ${var.app_name}"
  project      = var.gcp_project_id
}

# Granting IAM roles to the Cloud Run Service Account
resource "google_project_iam_member" "cloud_run_invoker_binding" {
  project = var.gcp_project_id
  role    = "roles/run.invoker" # Allows invocation of Cloud Run services
  member  = "serviceAccount:${google_service_account.cloud_run_sa.email}"
}

# Granting IAM roles to the Cloud Run Service Account to pull images
resource "google_project_iam_member" "cloud_run_gcr_pull_binding" {
  project = var.gcp_project_id
  role    = "roles/storage.objectViewer" # To pull images from GCR
  member  = "serviceAccount:${google_service_account.cloud_run_sa.email}"
}

# Cloud Run Service
resource "google_cloud_run_v2_service" "app_service" {
  name     = var.app_name
  location = var.gcp_region
  project  = var.gcp_project_id

  template {
    containers {
      image = "${var.gcp_project_id}/gcr.io/${var.app_name}:latest" # Placeholder image, will be updated by GitHub Actions
      ports {
        container_port = 5000 # Port exposed by the Flask application
      }
    }
    service_account = google_service_account.cloud_run_sa.email
    timeout         = "300s" # 5 minutes
    scaling {
      min_instance_count = 0
      max_instance_count = 2
    }
  }

  # Allowing public access to the Cloud Run service
  traffic {
    type    = "TRAFFIC_TARGET_ALLOCATION_TYPE_LATEST"
    percent = 100
  }
}

# Allowing unauthenticated access to Cloud Run
resource "google_cloud_run_v2_service_iam_member" "public_access" {
  name     = google_cloud_run_v2_service.app_service.name
  location = google_cloud_run_v2_service.app_service.location
  project  = var.gcp_project_id
  role     = "roles/run.invoker"
  member   = "allUsers"
}
  • This file defines the infrastructure on AWS and GCP.
  • AWS: An ECR repository for Docker images, a Security Group for HTTP/SSH, a Key Pair for SSH, and a t2.micro EC2 instance. The EC2 user data will install Docker.
  • GCP: A dedicated Service Account for Cloud Run, and the Cloud Run service itself. Cloud Run will be configured to expose port 5000.
  • Ensure you replace `~/.ssh/id_rsa.pub` with the actual path to your SSH public key. If you don't have one, create it with `ssh-keygen`.
  • Replace `var.gcp_project_id` with your GCP project ID if not set in `variables.tf`.
Create file terraform/outputs.tf:
# outputs.tf
output "aws_ecr_repository_url" {
  description = "URL ECR Repository"
  value       = aws_ecr_repository.app_repo.repository_url
}

output "aws_ec2_public_ip" {
  description = "Public IP Address of the EC2 instance"
  value       = aws_instance.app_server.public_ip
}

output "gcp_cloud_run_url" {
  description = "URL of the Google Cloud Run service"
  value       = google_cloud_run_v2_service.app_service.uri
}
  • This file will display important information after Terraform completes provisioning, such as the ECR URL, EC2 public IP, and Cloud Run URL.
Initialize Terraform and apply the configuration:
cd terraform
terraform init
terraform plan -var="gcp_project_id=<YOUR_GCP_PROJECT_ID>" # Replace with your GCP project ID
terraform apply -var="gcp_project_id=<YOUR_GCP_PROJECT_ID>" --auto-approve
  • Ensure you replace `` with your Google Cloud project ID.
  • This process will take a few minutes. Once completed, you will see output with the ECR URL, EC2 IP, and Cloud Run URL. Save these values.
ℹ️ Info: Terraform will create `terraform.tfstate` in the `terraform` directory. This file contains the state of your infrastructure. Never delete or modify this file manually, and never store it directly in a Git repository. For production, use a remote backend like AWS S3 or Terraform Cloud.

Step 4: Configure GitHub Secrets

To keep cloud credentials secure, we will store them as GitHub Secrets in your repository.
  1. Create a new GitHub repository or use an existing one for your `multi-cloud-ci-cd` project. Push the code you've already created:
    cd .. # Return to project root
    git add .
    git commit -m "Initial Flask app and Terraform setup"
    git branch -M main
    git remote add origin <YOUR_GITHUB_REPO_URL>
    git push -u origin main
    
  2. In your GitHub repository, navigate to Settings > Secrets and variables > Actions. Click "New repository secret".
  3. Add the following secrets:
    • AWS_ACCESS_KEY_ID: Enter the AWS Access Key ID you obtained in Step 2.
    • AWS_SECRET_ACCESS_KEY: Enter the AWS Secret Access Key you obtained in Step 2.
    • GCP_PROJECT_ID: Enter your Google Cloud project ID.
    • GCP_SERVICE_ACCOUNT_KEY: Fill with the **base64-encoded JSON key of your GCP Service Account**. To get this value, run the following command in your terminal (replace ``):
      cat <PATH_TO_YOUR_SERVICE_ACCOUNT_JSON> | base64 -w 0
      
      Copy the output and paste it as the secret value.
    • AWS_SSH_PRIVATE_KEY: This is your SSH private key (`~/.ssh/id_rsa` or another file you use for SSH to EC2). To get this value, run the following command:
      cat ~/.ssh/id_rsa # or path to your private key
      
      Copy the output. Ensure there are no extra newline characters at the end when pasting.

Step 5: Build the CI/CD Pipeline with GitHub Actions

Now we will create a GitHub Actions workflow that will automatically build the Docker image, push it to ECR and GCR, and then deploy it to AWS EC2 and Google Cloud Run. Create the `.github/workflows` directory in your project root:
mkdir -p .github/workflows
touch .github/workflows/main.yml
Create file .github/workflows/main.yml:
# .github/workflows/main.yml
name: Multi-Cloud CI/CD Pipeline

on:
  push:
    branches:
      - main # Trigger workflow every time there's a push to the 'main' branch

env:
  AWS_REGION: ap-southeast-1 # Replace with your AWS region
  GCP_REGION: asia-southeast1 # Replace with your GCP region
  APP_NAME: flask-book-api # Our application name
  ECR_REPOSITORY: flask-book-api # ECR repository name

jobs:
  build-and-deploy:
    runs-on: ubuntu-latest # Run the job on the latest Ubuntu runner

    steps:
    - name: Checkout code
      uses: actions/checkout@v4

    # --- AWS Configuration ---
    - 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

    # --- GCP Configuration ---
    - name: Authenticate to Google Cloud
      id: auth_gcp
      uses: google-github-actions/auth@v2
      with:
        credentials_json: ${{ secrets.GCP_SERVICE_ACCOUNT_KEY }}

    - name: Set up GCP Project ID
      run: |
        echo "GCP_PROJECT_ID=${{ secrets.GCP_PROJECT_ID }}" >> $GITHUB_ENV

    # --- Docker Build & Push ---
    - name: Build and push Docker image to ECR
      env:
        ECR_REGISTRY: ${{ steps.login-ecr.outputs.registry }}
        IMAGE_TAG: latest
      run: |
        docker build -t $ECR_REGISTRY/$ECR_REPOSITORY:$IMAGE_TAG .
        docker push $ECR_REGISTRY/$ECR_REPOSITORY:$IMAGE_TAG

    - name: Build and push Docker image to GCR
      env:
        GCR_REGISTRY: gcr.io/${{ env.GCP_PROJECT_ID }} # GCR uses project ID format
        IMAGE_TAG: latest
      run: |
        gcloud auth configure-docker
        docker build -t $GCR_REGISTRY/$APP_NAME:$IMAGE_TAG .
        docker push $GCR_REGISTRY/$APP_NAME:$IMAGE_TAG

    # --- AWS EC2 Deployment ---
    - name: Get EC2 Instance Public IP
      id: get_ec2_ip
      run: |
        # Using AWS CLI to get the public IP of the EC2 instance
        # Ensure Name=${APP_NAME}-ec2 tagging matches in Terraform
        EC2_IP=$(aws ec2 describe-instances \
          --filters "Name=tag:Name,Values=${{ env.APP_NAME }}-ec2" "Name=instance-state-name,Values=running" \
          --query "Reservations[0].Instances[0].PublicIpAddress" --output text)
        echo "EC2_IP=$EC2_IP" >> $GITHUB_ENV
        echo "::notice::EC2 Public IP: $EC2_IP"

    - name: Deploy to AWS EC2 via SSH
      if: env.EC2_IP != 'None' # Only deploy if IP is found
      uses: appleboy/ssh-action@v1.0.3
      with:
        host: ${{ env.EC2_IP }}
        username: ec2-user # Default user for Amazon Linux
        key: ${{ secrets.AWS_SSH_PRIVATE_KEY }}
        script: |
          sudo docker stop ${{ env.APP_NAME }} || true
          sudo docker rm ${{ env.APP_NAME }} || true
          sudo docker login --username AWS -p $(aws ecr get-login-password --region ${{ env.AWS_REGION }}) ${{ steps.login-ecr.outputs.registry }}
          sudo docker pull ${{ steps.login-ecr.outputs.registry }}/${{ env.ECR_REPOSITORY }}:latest
          sudo docker run -d --name ${{ env.APP_NAME }} -p 5000:5000 ${{ steps.login-ecr.outputs.registry }}/${{ env.ECR_REPOSITORY }}:latest
          echo "Deployed to AWS EC2 successfully!"

    # --- GCP Cloud Run Deployment ---
    - name: Deploy to Google Cloud Run
      uses: google-github-actions/deploy-cloudrun@v2
      with:
        service: ${{ env.APP_NAME }}
        region: ${{ env.GCP_REGION }}
        image: gcr.io/${{ env.GCP_PROJECT_ID }}/${{ env.APP_NAME }}:latest
        allow_unauthenticated: true # For public access
        # env_vars: # If there are environment variables
        #  MY_VAR: "value"
  • This workflow will be triggered every time there's a change in the `main` branch.
  • It uses `aws-actions/configure-aws-credentials` and `google-github-actions/auth` to configure cloud credentials using GitHub Secrets.
  • The Docker image will be built and pushed to Amazon ECR and Google Container Registry (GCR).
  • For AWS EC2: The pipeline will retrieve the EC2 public IP, then use `appleboy/ssh-action` to SSH into the instance. There, it will log in to ECR, pull the latest image, stop the old container, and run the new container.
  • For GCP Cloud Run: It uses `google-github-actions/deploy-cloudrun` to deploy the latest image from GCR to the existing Cloud Run service.
💡 Tip: Ensure `EC2_IP` is successfully retrieved. If your EC2 instance doesn't have a public IP or isn't ready, this step might fail. Check GitHub Actions logs for debugging.

Step 6: Test and Verify Deployment

Now it's time to test our entire CI/CD pipeline!
  1. Commit and push your GitHub Actions workflow file to the `main` branch:
    git add .github/workflows/main.yml
    git commit -m "Add GitHub Actions CI/CD workflow"
    git push origin main
    
  2. Open your GitHub repository, navigate to the "Actions" tab. You will see the "Multi-Cloud CI/CD Pipeline" workflow running. Click on the workflow to view its step details.
  3. Wait until all steps (Build & Push Docker, Deploy to AWS EC2, Deploy to Google Cloud Run) complete with a "Success" status. This may take a few minutes.
  4. Once the workflow is complete, you'll need to get the URLs to test the application.
    • For AWS EC2, use the public IP you obtained from the Terraform output (`terraform output aws_ec2_public_ip`) or from the GitHub Actions logs in the "Get EC2 Instance Public IP" step.
    • For Google Cloud Run, use the URL you obtained from the Terraform output (`terraform output gcp_cloud_run_url`) or from the GitHub Actions logs in the "Deploy to Google Cloud Run" step (look for the service URL).
  5. Test the API using `curl` or a browser: Test AWS EC2:
    # Replace <EC2_PUBLIC_IP> with your EC2 public IP
    curl http://<EC2_PUBLIC_IP>:5000/books
    
    Expected output:
    [
          {
            "id": 1,
            "title": "The Hitchhiker's Guide to the Galaxy",
            "author": "Douglas Adams"
          },
          {
            "id": 2,
            "title": "Pride and Prejudice",
            "author": "Jane Austen"
          }
        ]
        
    Test Google Cloud Run:
    # Replace <GCP_CLOUD_RUN_URL> with your Cloud Run service URL
    curl <GCP_CLOUD_RUN_URL>/books
    
    Expected output:
    [
          {
            "id": 1,
            "title": "The Hitchhiker's Guide to the Galaxy",
            "author": "Douglas Adams"
          },
          {
            "id": 2,
            "title": "Pride and Prejudice",
            "author": "Jane Austen"
          }
        ]
        
Congratulations! You now have a fully functional multi-cloud CI/CD system. Every time you make changes to your application code and push them to the `main` branch, GitHub Actions will automatically build, push, and deploy your application to both AWS EC2 and Google Cloud Run!

Common Errors and How to Resolve Them

Error: "Error: No valid credential sources found" (Terraform/AWS CLI)

Cause: Terraform or AWS CLI cannot find your AWS credentials. This can happen if `aws configure` hasn't been run, the entered credentials are incorrect, or AWS environment variables (`AWS_ACCESS_KEY_ID`, `AWS_SECRET_ACCESS_KEY`) are not set.

Solution: Ensure you have run aws configure and entered the correct `Access Key ID` and `Secret Access Key`. Verify with `aws sts get-caller-identity`. If using environment variables, ensure they are correctly exported in your shell.

Error: "Error: google: could not find default credentials. See https://cloud.google.com/docs/authentication/getting-started" (Terraform/GCP CLI)

Cause: Terraform or gcloud CLI cannot find your Google Cloud credentials. This often happens if `gcloud auth application-default login` hasn't been run or the service account credentials haven't been activated.

Solution: Ensure you have run gcloud auth application-default login and gcloud auth activate-service-account --key-file=<PATH_TO_YOUR_SERVICE_ACCOUNT_JSON> locally. For GitHub Actions, ensure `GCP_SERVICE_ACCOUNT_KEY` in GitHub Secrets is correctly base64-encoded and has sufficient permissions.

Error: "Host key verification failed." or "Permission denied (publickey)." (GitHub Actions SSH to EC2)

Cause: GitHub Actions cannot connect to the EC2 instance via SSH. This is usually because the EC2 instance's SSH public key does not match the private key provided in GitHub Secrets, or the EC2 instance is not ready, or the Security Group does not allow SSH.

Solution:

  1. Ensure `AWS_SSH_PRIVATE_KEY` in GitHub Secrets is the **exact same** private key as the public key you used when creating the `aws_key_pair` in Terraform. Make sure there are no unnecessary spaces or newlines.
  2. Verify that the EC2 Security Group (`aws_security_group.app_sg`) allows inbound SSH traffic (port 22) from `0.0.0.0/0` (for the tutorial).
  3. Try SSHing manually from your local machine to the EC2 instance to ensure the connection works before blaming GitHub Actions.
  4. Ensure the EC2 instance is fully running and Docker is installed (check `user_data` logs in the EC2 instance's System Log).

Error: "The user-provided container image is not accessible." (GCP Cloud Run Deployment)

Cause: Cloud Run cannot pull the Docker image from GCR. This could be because the image was not successfully pushed to GCR, the image name in the workflow is incorrect, or the Cloud Run Service Account does not have sufficient permissions to read from GCR.

Solution:

  1. Check GitHub Actions logs for the "Build and push Docker image to GCR" step to ensure the image was successfully pushed.
  2. Verify that the image name in the workflow (`gcr.io/${{ env.GCP_PROJECT_ID }}/${{ env.APP_NAME }}:latest`) matches the pushed image.
  3. Ensure `google_service_account.cloud_run_sa` has the `roles/storage.objectViewer` role in your GCP project.

Tips and Best Practices

  • Secure Terraform State Management: For production, avoid keeping `terraform.tfstate` files locally. Use a remote backend such as AWS S3, Azure Blob Storage, or Terraform Cloud to store Terraform state securely, encrypted, and to support team collaboration.
  • Principle of Least Privilege (PoLP) for IAM: Always grant the minimum necessary permissions for your cloud credentials (AWS IAM User, GCP Service Account). Avoid granting `*FullAccess` in production environments. Audit and restrict permissions according to specific roles.
  • Use Environment Variables: For application configurations that can change between environments (dev, staging, prod), use environment variables instead of hardcoding them in the code. GitHub Actions can inject these into your containers.
  • Implement Automated Testing: Before deployment, add automated testing steps (unit tests, integration tests) to your GitHub Actions pipeline. This will catch bugs earlier and prevent deploying broken code.
  • Advanced Deployment Strategies: Consider more advanced deployment strategies like Blue/Green Deployment or Canary Deployment to minimize downtime and risk during major changes. These can be implemented with Load Balancer features or traffic management in services like Cloud Run.
💡 Tip: For more complex applications, consider separating infrastructure and application code into different repositories. This aids management and scalability.

FAQ

Why use Terraform for infrastructure? Can't I just use the cloud console directly?

Using Terraform allows you to define infrastructure as code (IaC). This means your infrastructure can be version-controlled, reviewed, and applied consistently. It eliminates configuration "drift," simplifies environment reproduction, and accelerates provisioning, especially in multi-cloud scenarios.

Can I deploy to other clouds besides AWS and Google Cloud?

Absolutely! Terraform supports many cloud providers (Azure, DigitalOcean, Oracle Cloud, etc.), and GitHub Actions also has integrations or third-party actions for various platforms. The concept remains the same: define infrastructure with Terraform, and automate deployment with GitHub Actions.

What if my application needs a persistent database?

For a persistent database, you would add database resources (e.g., AWS RDS, Google Cloud SQL) to your Terraform configuration. Your Flask application would then need to be configured to connect to that database using environment variables provided during deployment.

Are GitHub Actions secure for sensitive credentials?

Yes, GitHub Actions Secrets are designed to securely store sensitive credentials. Secret values are encrypted and never exposed in workflow logs. However, it's crucial to still follow security best practices, such as restricting who has access to these secrets and employing the principle of least privilege.

Conclusion

Congratulations, fellow tech enthusiast! You have successfully built and implemented a modern CI/CD pipeline that automates the deployment of a simple Flask application to two different cloud platforms: AWS EC2 and Google Cloud Run. You've used Terraform to define infrastructure as code, ensuring consistency and ease of management, and GitHub Actions to orchestrate the entire CI/CD process, from Docker image build to final deployment. This small project serves as a strong foundation. From here, you can further develop it by adding persistent databases, integrating more comprehensive automated testing, implementing more advanced deployment strategies, or even exploring other cloud providers. Remember, the main goal of this Modern CI/CD Tutorial: Automating Multi-Cloud Deployment Using Terraform and GitHub Actions is to provide you with practical experience and tangible results. Feel free to experiment and deepen your understanding. If you have any questions or encounter difficulties, don't hesitate to ask in the comments section below. Let's learn and grow together!
Bahasa: EN ↑