Github Actions to deploy your Terraform code
Tags: cicd, terraform, googlecloud
Published: 2024-05-21 09:41:28

If you have chosen to host some of your codebases on [Github](https://github.com/), you should know that there is a built-in tool allowing you to implement [CI/CD](https://www.redhat.com/en/topics/devops/what-is-ci-cd) workflows, called [Github Actions](https://github.com/features/actions).

My goal in this article is to show you that with just a few clicks and writing yaml, you can properly deploy your Terraform code on your favorite cloud provider (in this example we will use Google Cloud).

## Why CI/CD

In short, the main advantages that I see of going through a CI/CD pattern (Continuous Integration / Continuous Deployment), rather than deploying the code directly from your computer, are :

- **Maintenance** : Your deployment code will be reproducible and versioned
- **Traceability** : You will have a history of different deployments and past errors
- **Teamwork** : Deployment will be accessible to all those included in the project, without complicated configuration

For a more exhaustive explanation, I refer you to the reference which is the [12factor app](https://12factor.net/).

## Why Github Actions

Github Actions has many competitors in its category that allow you to run all kinds of code running on containers, such as [Gitlab](https://about.gitlab.com/solutions/continuous-integration/), [Jenkins](https://www.jenkins.io/), [CircleCI](https://circleci.com/), etc.

There are also more specialized tools in deploying IaC code, such as: [HCP Terraform](https://app.terraform.io/), [Atlantis](https://www.runatlantis.io/), [Terramate](https://terramate.io/), etc.

My choice fell on Github Actions, aiming to have the **lightest footprint possible**. 

Since my code is already on the Github platform, this gives me the ability to execute code, without having to connect to an additional tool. The possibilities are extensive, I can run all kinds of containers, whether for infrastructure or not.

It is also a tool with a **strong community**, which can be frequently encountered on consulting missions.

## Pricing

Considering the [billing documention](https://docs.github.com/en/billing/managing-billing-for-github-actions/about-billing-for-github-actions), for personal use, you can use it free of charge within certain limitations per month for private repositories (2000 minutes of build and 500 Mo of package storage, by month). You also have the possibility to connect your own agents in addition.

Good news is that Github encourages open-source by offering unlimited use for publicly exposed repositories.

If you want to extend the possibilities of your personal account, you can take [Github Pro](https://docs.github.com/en/billing/managing-the-plan-for-your-github-account/upgrading-your-accounts-plan), for $4/month.

If the company you work for has [subscribed to Github](https://github.com/pricing), you probably benefit from a more substantial offer with additional features ([GitHub Team](https://github.com/team) or [GitHub Enterprise](https://github.com/enterprise)).

## Let’s do the demo

In our example, I only selected the basic features available in the free offer, which can therefore be used in all contexts.

Here is the Terraform code you can use to create a Cloud function on Google Cloud :

```bash
# main.tf
resource "**google_storage_bucket_object**" "object" {
  name   = "functions/test1.zip"
  bucket = var.bucket_name
  source = "./functions/test1.zip" # Local path to the zipped function source code
}

resource "google_cloudfunctions2_function" "function" {
  name        = "mycorp-test1"
  location    = var.region
  description = "Test1 function"

  build_config {
    runtime     = "python312"
    entry_point = "main"
    source {
      storage_source {
        bucket = var.bucket_name
        object = google_storage_bucket_object.object.name
      }
    }
  }

  service_config {
    max_instance_count = 1
    available_memory   = "256M"
    timeout_seconds    = 60
  }
}

# output.tf
output "function_uri" {
  value = google_cloudfunctions2_function.function.service_config[0].uri
}

# provider.tf
provider "google" {
  credentials = file("credentials.json")

  project = var.project_name
  region  = var.region
  zone    = var.zone
}

terraform {
  backend "gcs" {
    bucket      = "infra-mycorp-terraform-states"
    prefix      = "gha-demo"
    credentials = "credentials.json"
  }
  required_version = ">=1.7.0"
  required_providers {
    google = {
      source  = "hashicorp/google"
      version = ">=4.0.0"
    }
  }
}

# variables.tf
variable "region" {
  type        = string
  default     = "europe-west1"
  description = "GCP Region"
}

variable "zone" {
  type        = string
  default     = "europe-west1-c"
  description = "GCP Zone"
}

variable "project_name" {
  type        = string
  default     = "infra-mycorp"
  description = "GCP Project Name"
}

variable "bucket_name" {
  type        = string
  default     = "mycorp-packages"
  description = "Bucket name"
}
```

You may also [generate a json key](https://cloud.google.com/iam/docs/keys-create-delete) for your Service Account.

Here is the Python code you can use to create an example package for your Cloud Function, to store on a Cloud Storage bucket : 

```python
import functions_framework

@functions_framework.http
def main(request):
    """HTTP Cloud Function.
    Args:
        request (flask.Request): The request object.
        <https://flask.palletsprojects.com/en/1.1.x/api/#incoming-request-data>
    Returns:
        The response text, or any set of values that can be turned into a
        Response object using `make_response`
        <https://flask.palletsprojects.com/en/1.1.x/api/#flask.make_response>.
    """
    request_json = request.get_json(silent=True)
    request_args = request.args

    if request_json and 'name' in request_json:
        name = request_json['name']
    elif request_args and 'name' in request_args:
        name = request_args['name']
    else:
        name = 'World'
    return 'Hello {}!'.format(name)
```

And here the Github Actions workflows to execute your Terraform code :

```yaml
# .github/workflows/1_plan.yml
name: 1. Plan
run-name: Run Workflow ${{ github.run_id }} 🚀
on:
  workflow_dispatch:
  push:
    branches: [main]
  pull_request:
    branches: [main]
jobs:
  plan:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - run: echo "🚀 Plan launched"
      - name: Install packages
        run: |
          sudo apt-get update
          sudo apt-get install -y build-essential python3
      - name: Install Homebrew
        run: |
          /bin/bash -c "$(curl -fsSL https://raw.githubusercontent.com/Homebrew/install/HEAD/install.sh)"
          rc=/tmp/rcfile && touch $rc
          echo 'eval $(/home/linuxbrew/.linuxbrew/bin/brew shellenv)' >> $rc
      - name: Install Terraform
        run: |
          source /tmp/rcfile
          brew install tfenv
          tfenv install 1.8.3
          tfenv use 1.8.3
          terraform version
      - name: Load credentials
        run: |
          echo -n '${{ secrets.GOOGLE_CREDENTIALS }}' | base64 -d > credentials.json
      - name: Terraform Init
        run: |
          terraform init -get=true -upgrade
          terraform workspace new ${{ vars.WORKSPACE_DEV }} || true
          terraform workspace select ${{ vars.WORKSPACE_DEV }}
      - name: Terraform Format
        run: |
          terraform fmt
      - name: Terraform Plan
        run: |
          terraform plan
      - run: echo "🍏 The job's status is ${{ job.status }}."

# .github/workflows/2_apply.yml
name: 2. Apply
run-name: Run Workflow ${{ github.run_id }} 🚀
on:
  workflow_dispatch:
jobs:
  apply:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - run: echo "🚀 Plan launched"
      - name: Install packages
        run: |
          sudo apt-get update
          sudo apt-get install -y build-essential python3
      - name: Install Homebrew
        run: |
          /bin/bash -c "$(curl -fsSL https://raw.githubusercontent.com/Homebrew/install/HEAD/install.sh)"
          rc=/tmp/rcfile && touch $rc
          echo 'eval $(/home/linuxbrew/.linuxbrew/bin/brew shellenv)' >> $rc
      - name: Install Terraform
        run: |
          source /tmp/rcfile
          brew install tfenv
          tfenv install 1.8.3
          tfenv use 1.8.3
          terraform version
      - name: Load credentials
        run: |
          echo -n '${{ secrets.GOOGLE_CREDENTIALS }}' | base64 -d > credentials.json
      - name: Terraform Init
        run: |
          terraform init -get=true -upgrade
          terraform workspace new ${{ vars.WORKSPACE_DEV }} || true
          terraform workspace select ${{ vars.WORKSPACE_DEV }}
      - name: Terraform Apply
        run: |
          terraform apply -auto-approve
      - run: echo "🍏 The job's status is ${{ job.status }}."

# .github/workflows/3_destroy.yml
name: 3. Destroy
run-name: Run Workflow ${{ github.run_id }} 🚀
on:
  workflow_dispatch:
jobs:
  destroy:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - run: echo "🚀 Plan launched"
      - name: Install packages
        run: |
          sudo apt-get update
          sudo apt-get install -y build-essential python3
      - name: Install Homebrew
        run: |
          /bin/bash -c "$(curl -fsSL https://raw.githubusercontent.com/Homebrew/install/HEAD/install.sh)"
          rc=/tmp/rcfile && touch $rc
          echo 'eval $(/home/linuxbrew/.linuxbrew/bin/brew shellenv)' >> $rc
      - name: Install Terraform
        run: |
          source /tmp/rcfile
          brew install tfenv
          tfenv install 1.8.3
          tfenv use 1.8.3
          terraform version
      - name: Load credentials
        run: |
          echo -n '${{ secrets.GOOGLE_CREDENTIALS }}' | base64 -d > credentials.json
      - name: Terraform Init
        run: |
          terraform init -get=true -upgrade
          terraform workspace new ${{ vars.WORKSPACE_DEV }} || true
          terraform workspace select ${{ vars.WORKSPACE_DEV }}
      - name: Terraform Destroy
        run: |
          terraform apply -destroy -auto-approve
      - run: echo "🍏 The job's status is ${{ job.status }}."
```

## Some explanations on the workflows

### Triggers

In the [“on:” section](https://docs.github.com/en/actions/using-workflows/events-that-trigger-workflows), I created a rule to execute the “Plan” workflow, when there is a commit or a pull request on the “main” branch.

I created 3 files (one file for each workflow, inside the “./.github/workflows” folder), with the [“workflow_dispatch:” section](https://docs.github.com/en/actions/using-workflows/manually-running-a-workflow), we have the possibility to trigger manually an “apply” or a “delete”, once the plan is done (or to relaunch the plan).

Note that I didn’t use the [manual approval feature](https://cloudlumberjack.com/posts/github-actions-approvals/), to set manual validation inside a workflow, because it isn’t available in the free features.

Here is what shows the interface for a basic plan :

![List of workflows](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/u4o8481vq0v32upzlowp.png)

![Plan workflow](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/v0taz29saseyhv0pmdcf.png)

### Checkout

Ensure you have the [step “uses: actions/checkout”](https://docs.github.com/en/actions/learn-github-actions/understanding-github-actions#create-an-example-workflow), because it is required if you want to have your code inside your container.

### Variables & secrets

Go in the Settings of your project, to consult [variables](https://docs.github.com/en/actions/learn-github-actions/variables) and [secrets](https://docs.github.com/en/actions/security-guides/using-secrets-in-github-actions) you want to store for your workflow. Then you can call them with the “var.” and “secrets.” prefixes.

There are some restrictions for secrets, once copied, you can’t consult them again from the interface. 

Another thing is that you a restricted to a certain number of characters for the value of your secret, a common trick I used is to encode it in base64, and decode it directly in the workflow.

![Variables](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/zb46ogicjn99dckqlbif.png)

### Packaging

I used the [“ubuntu-latest” image](https://github.com/actions/runner-images) which is Debian-based (with [apt](https://www.debian.org/doc/manuals/debian-faq/pkgtools.en.html#apt-get)) and commonly available in the Github Actions workers, so it deploys very fast.

I chose to install [HomeBrew](https://docs.brew.sh/Homebrew-on-Linux), because there are a lot of tools I like available on this package manager, including [tfenv](https://dev.to/jdxlabs/tfenv-to-handle-different-versions-of-terraform-4k6p), which allows me to install any version of Terraform very easily.

### Validations and workspaces

The only validation I have installed is the [Terraform Format](https://developer.hashicorp.com/terraform/cli/commands/fmt) command, which indicates if the code is written according to the recommended formalism. It is of course possible and encouraged to install other validations.

The [workspace](https://developer.hashicorp.com/terraform/cli/commands/workspace) for Terraform allows you to compartmentalize the code according to different spaces and environments.

## To conclude

Here is a very simple way to deploy infrastructure on Google Cloud with a Github free account and some Terraform code. 

You can use as you wish and improve according to your preferences and needs.

It is also possible to interact Github Actions with a more specialized tool (like HCP Terraform or Terramate), which could be the subject of a future article.