Managing infrastructure as code with Terraform, Cloud Build, and GitOps

This tutorial explains how to manage infrastructure as code with Terraform and Cloud Build using the popular GitOps methodology. The term GitOps was first coined by Weaveworks, and its key concept is using a Git repository to store the environment state that you want. Terraform is a HashiCorp open source tool that enables you to predictably create, change, and improve your cloud infrastructure by using code. In this tutorial, you use Cloud Build, a Google Cloud continuous integration service, to automatically apply Terraform manifests to your environment.

This tutorial is for developers and operators who are looking for an elegant strategy to predictably make changes to infrastructure. The article assumes you are familiar with Google Cloud, Linux, and GitHub.

Architecture

To demonstrate how this tutorial applies GitOps practices for managing Terraform executions, consider the following architecture diagram. Note that it uses GitHub branches—dev and prod—to represent actual environments. These environments are defined by Virtual Private Cloud (VPC) networks—dev and prod, respectively—into a Google Cloud project.

Infrastructure with dev and prod environments.

The process starts when you push Terraform code to either the dev or prod branch. In this scenario, Cloud Build triggers and then applies Terraform manifests to achieve the state you want in the respective environment. On the other hand, when you push Terraform code to any other branch—for example, to a feature branch—Cloud Build runs to execute terraform plan, but nothing is applied to any environment.

Ideally, either developers or operators must make infrastructure proposals to non-protected branches and then submit them through pull requests. The Cloud Build GitHub app, discussed later in this tutorial, automatically triggers the build jobs and links the terraform plan reports to these pull requests. This way, you can discuss and review the potential changes with collaborators and add follow-up commits before changes are merged into the base branch.

If no concerns are raised, you must first merge the changes to the dev branch. This merge triggers an infrastructure deployment to the dev environment, allowing you to test this environment. After you have tested and are confident about what was deployed, you must merge the dev branch into the prod branch to trigger the infrastructure installation to the production environment.

Objectives

  • Set up your GitHub repository.
  • Configure Terraform to store state in a Cloud Storage bucket.
  • Grant permissions to your Cloud Build service account.
  • Connect Cloud Build to your GitHub repository.
  • Change your environment configuration in a feature branch.
  • Promote changes to the development environment.
  • Promote changes to the production environment.

Costs

This tutorial uses the following billable components of Google Cloud:

To generate a cost estimate based on your projected usage, use the pricing calculator. New Google Cloud users might be eligible for a free trial.

Before you begin

  1. Sign in to your Google Account.

    If you don't already have one, sign up for a new account.

  2. In the Cloud Console, on the project selector page, select or create a Cloud project.

    Go to the project selector page

  3. Make sure that billing is enabled for your Google Cloud project. Learn how to confirm billing is enabled for your project.

  4. In the Cloud Console, activate Cloud Shell.

    Activate Cloud Shell

    At the bottom of the Cloud Console, a Cloud Shell session starts and displays a command-line prompt. Cloud Shell is a shell environment with the Cloud SDK already installed, including the gcloud command-line tool, and with values already set for your current project. It can take a few seconds for the session to initialize.

  5. In Cloud Shell, get the ID of the project you just selected:
    gcloud config get-value project
    If this command doesn't return the project ID, configure Cloud Shell to use your project. Replace PROJECT_ID with your project ID.
    gcloud config set project PROJECT_ID
  6. Enable the required APIs:
    gcloud services enable cloudbuild.googleapis.com compute.googleapis.com
    This step might take a few minutes to finish.
  7. If you've never used Git in Cloud Shell, configure it with your name and email address:
    git config --global user.email "your-email-address"
    git config --global user.name "your-name"
    
    Git uses this information to identify you as the author of the commits that you create in Cloud Shell.

When you finish this tutorial, you can avoid continued billing by deleting the resources you created. For more information, see Cleaning up.

Setting up your GitHub repository

In this tutorial, you use a single Git repository to define your cloud infrastructure. You orchestrate this infrastructure by having different branches corresponding to different environments:

  • The dev branch contains the latest changes that are applied to the development environment.
  • The prod branch contains the latest changes that are applied to the production environment.

With this infrastructure, you can always reference the repository to know what configuration is expected in each environment and to propose new changes by first merging them into the dev environment. You then promote the changes by merging the dev branch into the subsequent prod branch.

To get started, you fork the solutions-terraform-cloudbuild-gitops repository.

  1. On GitHub, navigate to https://github.com/GoogleCloudPlatform/solutions-terraform-cloudbuild-gitops.git.
  2. In the top-right corner of the page, click Fork.

    Forking a repository.

    Now you have a copy of the solutions-terraform-cloudbuild-gitops repository with source files.

  3. In Cloud Shell, clone this forked repository, replacing your-github-username with your GitHub username:

    cd ~
    git clone https://github.com/your-github-username/solutions-terraform-cloudbuild-gitops.git
    cd ~/solutions-terraform-cloudbuild-gitops
    

The code in this repository is structured as follows:

  • The environments/ folder contains subfolders that represent environments, such as dev and prod, which provide logical separation between workloads at different stages of maturity, development and production, respectively. Although it's a good practice to have these environments as similar as possible, each subfolder has its own Terraform configuration to ensure they can have unique settings as necessary.

  • The modules/ folder contains inline Terraform modules. These modules represent logical groupings of related resources and are used to share code across different environments.

  • The cloudbuild.yaml file is a build configuration file that contains instructions for Cloud Build, such as how to perform tasks based on a set of steps. This file specifies a conditional execution depending on the branch Cloud Build is fetching the code from, for example:

    • For dev and prod branches, the following steps are executed:

      1. terraform init
      2. terraform plan
      3. terraform apply
    • For any other branch, the following steps are executed:

      1. terraform init for all environments subfolders
      2. terraform plan for all environments subfolders

The reason terraform init and terraform plan run for all environments subfolders is to make sure that the changes being proposed hold for every single environment. This way, before merging the pull request, you can review the plans to make sure access is not being granted to an unauthorized entity, for example.

Configuring Terraform to store state in a Cloud Storage bucket

By default, Terraform stores state locally in a file named terraform.tfstate. This default configuration can make Terraform usage difficult for teams, especially when many users run Terraform at the same time and each machine has its own understanding of the current infrastructure.

To help you avoid such issues, this section configures a remote state that points to a Cloud Storage bucket. Remote state is a feature of backends and, in this tutorial, is configured in the backend.tf files—for example:

# Copyright 2019 Google LLC
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
#     https://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.


terraform {
  backend "gcs" {
    bucket = "PROJECT_ID-tfstate"
    prefix = "env/dev"
  }
}

In the following steps, you create a Cloud Storage bucket and change a few files to point to your new bucket and your Google Cloud project.

  1. In Cloud Shell, create the Cloud Storage bucket:

    PROJECT_ID=$(gcloud config get-value project)
    gsutil mb gs://${PROJECT_ID}-tfstate
    
  2. Enable Object Versioning to keep the history of your deployments:

    gsutil versioning set on gs://${PROJECT_ID}-tfstate
    

    Enabling Object Versioning increases storage costs, which you can mitigate by configuring Object Lifecycle Management to delete old state versions.

  3. Replace the PROJECT_ID placeholder with the project ID in both the terraform.tfvars and backend.tf files:

    cd ~/solutions-terraform-cloudbuild-gitops
    sed -i s/PROJECT_ID/$PROJECT_ID/g environments/*/terraform.tfvars
    sed -i s/PROJECT_ID/$PROJECT_ID/g environments/*/backend.tf
    
  4. Check whether all files were updated:

    git status
    

    The output looks like this:

    On branch dev
    Your branch is up-to-date with 'origin/dev'.
    Changes not staged for commit:
     (use "git add <file>..." to update what will be committed)
     (use "git checkout -- <file>..." to discard changes in working directory)
           modified:   environments/dev/backend.tf
           modified:   environments/dev/terraform.tfvars
           modified:   environments/prod/backend.tf
           modified:   environments/prod/terraform.tfvars
    no changes added to commit (use "git add" and/or "git commit -a")
    
  5. Commit and push your changes:

    git add --all
    git commit -m "Update project IDs and buckets"
    git push origin dev
    

    Depending on your GitHub configuration, you will have to authenticate to push the preceding changes.

Granting permissions to your Cloud Build service account

To allow Cloud Build service account to run Terraform scripts with the goal of managing Google Cloud resources, you need to grant it appropriate access to your project. For simplicity, project editor access is granted in this tutorial. But when the project editor role has a wide-range permission, in production environments you must follow your company's IT security best practices, usually providing least-privileged access.

  1. In Cloud Shell, retrieve the email for your project's Cloud Build service account:

    CLOUDBUILD_SA="$(gcloud projects describe $PROJECT_ID \
        --format 'value(projectNumber)')@cloudbuild.gserviceaccount.com"
    
  2. Grant the required access to your Cloud Build service account:

    gcloud projects add-iam-policy-binding $PROJECT_ID \
        --member serviceAccount:$CLOUDBUILD_SA --role roles/editor
    

Directly connecting Cloud Build to your GitHub repository

This section shows you how to install the Cloud Build GitHub app. This installation allows you to connect your GitHub repository with your Google Cloud project so that Cloud Build can automatically apply your Terraform manifests each time you create a new branch or push code to GitHub.

The following steps provide instructions for installing the app only for the solutions-terraform-cloudbuild-gitops repository, but you can choose to install the app for more or all your repositories.

  1. Go to the GitHub Marketplace page for the Cloud Build app:

    Open the Cloud Build app page

  2. If this is your first time configuring an app in GitHub, click Setup with Google Cloud Build. Otherwise, click Edit your plan, select your billing information and, in the Edit your plan page, click grant this app access.

  3. In the Install Google Cloud Build page, select Only select repositories and enter your-user/solutions-terraform-cloudbuild-gitops to connect to your forked repository.

  4. Click Install.

  5. Sign in to Google Cloud.

    The Authorization page is displayed. You are asked to authorize the Cloud Build GitHub app to connect to Google Cloud.

    Sign in to Google Cloud

  6. Click Authorize Google Cloud Build by GoogleCloudBuild.

    You are redirected to the Cloud Console.

  7. Select the Google Cloud project you are working on.

  8. If you agree to the terms and conditions, select the checkbox, and then click Next.

  9. In the Repository selection step, select your-user/solutions-terraform-cloudbuild-gitops to connect to your Google Cloud project, and then click Connect.

  10. Click Done, and then click Connect.

The Cloud Build GitHub app is now configured, and your GitHub repository is linked to your Google Cloud project. From now on, changes to the GitHub repository trigger Cloud Build executions, which report the results back to GitHub by using GitHub Checks.

Changing your environment configuration in a new feature branch

By now, you have most of your environment configured. So it's time to make some code changes in your development environment.

  1. On GitHub, navigate to the main page of your forked repository.
  2. Make sure you are in the dev branch.

    Make sure you're in the dev branch.

  3. To open the file for editing, go to the modules/firewall/main.tf file and click the pencil icon.

  4. On line 15, fix the "http-server**2**" typo in the target_tags field.

    The value must be "http-server".

  5. Add a commit message at the bottom of the page, such as "Fixing http firewall target", and select Create a new branch for this commit.

  6. Click Propose file change.

  7. On the following page, click Create pull request to open a new pull request with your change.

    Once your pull request is open, a Cloud Build job is automatically initiated.

  8. Click Show all checks and wait for the check to become green.

    Show all checks in a pull request.

  9. Click Details to see more information, including the output of the terraform plan at View more details on Google Cloud Build link.

Note that the Cloud Build job ran the pipeline defined in the cloudbuild.yaml file. As discussed previously, this pipeline has different behaviors depending on the branch being fetched. The build checks whether the $BRANCH_NAME variable matches any environment folder. If so, Cloud Build executes terraform plan for that environment. Otherwise, Cloud Build executes terraform plan for all environments to make sure that the proposed change holds for them all. If any of these plans fail to execute, the build fails as well.

- id: 'tf plan'
  name: 'hashicorp/terraform:0.11.14'
  entrypoint: 'sh'
  args: 
  - '-c'
  - | 
      if [ -d "environments/$BRANCH_NAME/" ]; then
        cd environments/$BRANCH_NAME
        terraform plan
      else
        for dir in environments/*/
        do 
          cd ${dir}   
          env=${dir%*/}
          env=${env#*/}  
          echo ""
          echo "*************** TERRAFOM PLAN ******************"
          echo "******* At environment: ${env} ********"
          echo "*************************************************"
          terraform plan || exit 1
          cd ../../
        done
      fi 

Similarly, the terraform apply command runs for environment branches, but it is completely ignored in any other case. In this section, you have submitted a code change to a new branch, so no infrastructure deployments were applied to your Google Cloud project.

- id: 'tf apply'
  name: 'hashicorp/terraform:0.11.14'
  entrypoint: 'sh'
  args: 
  - '-c'
  - | 
      if [ -d "environments/$BRANCH_NAME/" ]; then
        cd environments/$BRANCH_NAME      
        terraform apply -auto-approve
      else
        echo "***************************** SKIPPING APPLYING *******************************"
        echo "Branch '$BRANCH_NAME' does not represent an oficial environment."
        echo "*******************************************************************************"
      fi

Enforcing Cloud Build execution success before merging branches

To make sure merges can be applied only when respective Cloud Build executions are successful, proceed with the following steps:

  1. On GitHub, navigate to the main page of your forked repository.
  2. Under your repository name, click Settings.
  3. In the left menu, click Branches.
  4. Under Branch protection rules, click Add rule.
  5. In Branch name pattern, select dev.
  6. In Rule settings, select Require status checks to pass before merging, and then in Status checks found in the last week for this repository, click Build.
  7. Click Create.
  8. Repeat steps 5–8, setting Branch name pattern to prod.

This configuration is important to protect both the dev and prod branches. Meaning, commits must first be pushed to another branch, and only then they can be merged to the protected branch. In this tutorial, the protection requires that the Cloud Build execution be successful for the merge to be allowed.

Promoting changes to the development environment

You have a pull request waiting to be merged. It's time to apply the state you want to your dev environment.

  1. On GitHub, navigate to the main page of your forked repository.
  2. Under your repository name, click Pull requests.
  3. Click the pull request you just created.
  4. Click Merge pull request, and then click Confirm merge.

    Confirm merge.

  5. Check that a new Cloud Build has been triggered:

    Go to the Cloud Build page

  6. Open the build and check the logs.

    When the build finishes, you see something like this:

    Step #3 - "tf apply": external_ip = external-ip-value
    Step #3 - "tf apply": firewall_rule = dev-allow-http
    Step #3 - "tf apply": instance_name = dev-apache2-instance
    Step #3 - "tf apply": network = dev
    Step #3 - "tf apply": subnet = dev-subnet-01
    
  7. Copy external-ip-value and open the address in a web browser.

    This provisioning might take a few seconds to boot the VM and to propagate the firewall rule, but at the end, you see Environment: dev in the web browser.

Promoting changes to the production environment

Now that you have your development environment fully tested, you can promote your infrastructure code to production.

  1. On GitHub, navigate to the main page of your forked repository.
  2. Click New pull request.
  3. For the base repository, select your just-forked repository.
  4. For base, select prod and for compare, select dev.
  5. Click Create pull request
  6. For title, enter a title such as Promoting networking changes, and then click Create pull request.
  7. Review the proposed changes, including the terraform plan details from Cloud Build, and then click Merge pull request.
  8. Click Confirm merge.
  9. In the Cloud Console, open the Build History page to see your changes being applied to the production environment:

    Go to the Cloud Build page

  10. Wait for the build to finish, and then check the logs.

    At the end of the logs, you see something like this:

    Step #3 - "tf apply": external_ip = external-ip-value
    Step #3 - "tf apply": firewall_rule = prod-allow-http
    Step #3 - "tf apply": instance_name = prod-apache2-instance
    Step #3 - "tf apply": network = prod
    Step #3 - "tf apply": subnet = prod-subnet-01
    
  11. Copy external-ip-value and open the address in a web browser.

    This provisioning might take a few seconds to boot the VM and to propagate the firewall rule, but in the end, you see Environment: prod in the web browser.

You have successfully configured a serverless infrastructure-as-code pipeline on Cloud Build. In the future, you might want to try the following:

  • Add deployments for separate use cases.
  • Create additional environments to reflect your needs.
  • Use a project per environment instead of a VPC per environment.

Cleaning up

After you've finished the tutorial, clean up the resources you created on Google Cloud so you won't be billed for them in the future.

Deleting the project

  1. In the Cloud Console, go to the Manage resources page.

    Go to the Manage resources page

  2. In the project list, select the project that you want to delete and then click Delete .
  3. In the dialog, type the project ID and then click Shut down to delete the project.

What's next