In this multipart post I’ll go over the setup of a continuous integration pipeline on AWS CodeBuild, using Terraform to automate all the infrastructure configuration and creation. The first part is be about getting started, creating a build configuration for CodeBuild and setting up the basics for Terraform.

Requirements

  • a GitHub repository and a Personal Access Token with the repo scope
  • AWS credentials for an IAM user that is able to perform what will be specified in the Terraform declaration (in a real scenario we should respect the least privilege principle either by using a limited user role or by configuring the Terraform AWS provider to assume a role with limited access)

Sample project

Find the sample project here.

It contains a very simple Node.js app (a fastify server), a Dockerfile, a CodeBuild config file and an infra directory with the Terraform configurations.

You may also follow along with your own existing repo or by creating a sample app in whichever language you prefer. There is also a basic branch in the sample repo, containing just the app code. Everything specific to CodeBuild and Terraform will be added in steps along the article.

State

Terraform needs to store state about the infrastructure and its configuration. This can be simply a local file but in a team enviornment it is desirable to store it on a remote location. Fot that purpose, create an S3 bucket:

aws s3api create-bucket \
	--bucket "acme-tfstate-$(uuidgen)" \
	--region eu-west-1 \
	--create-bucket-configuration LocationConstraint=eu-west-1

AWS CodeBuild

This is the tool in charge of handling the CI pipeline, in which Docker images of the project will be built and then published to a private Amazon ECR Registry repository.

CodeBuild is relatively easy to use and is not different from other CI/CD tools out there. I won’t go into many details here, just the buildspec.yaml file, which is the file where we can configure the pipeline phases/steps.

However, there is a minor trap!

Like in most cases, the app image is built from a public image: node:16-alpine. If I don’t sign in first with my own Docker credentials, the requests to pull layers from dockerhub will be rate limited and the build will fail. The issue is described on this AWS blog post. There are essentially 2 solutions: to use Docker credentials or to clone the public image(s) you may be using in your Dockerfiles in your own ECR public/private registries. I opted for the former.

So, on the pre_build phase I configured the sign in with my Docker credentials, build builds the image, post_build signs in again with AWS credentials on the private repository and pushes the image there. Please note that this could be done in many different ways. Maybe in a single phase or doing the first docker login just before the docker build command. Or maybe I could have bash scripts with these instructions and would tell CloudBuild to run the scripts instead.

Create and edit a buildspec.yaml file in the project root:

version: 0.2

phases:
  pre_build:
    commands:
      - echo $DOCKERHUB_PASSWORD | docker login --username $DOCKERHUB_USERNAME --password-stdin
  build:
    commands:
      - docker build -t $ECR_REGISTRY:$CODEBUILD_SOURCE_VERSION .
  post_build:
    commands:
      - aws ecr get-login-password --region $AWS_REGION | docker login --username AWS --password-stdin $ECR_REGISTRY
      - docker push $ECR_REGISTRY:$CODEBUILD_SOURCE_VERSION

Like CircleCI and similar tools, CodeBuils exposes some default environment variables that may be useful for our scripts. Here I’ve used CODEBUILD_SOURCE_VERSION (the hash of the commit that triggered the build) as image tag. The remaining variables seen in the config are custom and I’ll get to them later.

Terraform

Now that the project is ready to be built, let’s take care of all the infrastructre without touching AWS’s Console.

Start by creating a directory to keep all the Terraform stuff and the files we’ll need as well:

mkdir infra && cd "$_"
touch main.tf         # will contain all infrastructure declaration
touch variables.tf    # will contain variables declarations
touch .tfvars         # will hold environment variables to be passed on to Terraform

Terraform works with modules in a way that resembles Go (actually, Terraform is built with Go so that’s probably why). All .tf files in a directory belong to a module. In this case only one module will be used (the root module).

The first thing to add to main.tf are the provider and backend configurations. Providers are plugins that implement interactions with APIs (in this case, AWS’s). Backends are where the Terraform state is stored, as explained in the state section.

For the backend, configure the S3 bucket created earlier. On the provider, the values contain secrets, therefore they shall not be part of the declaration. They come from variables that will be declare on variables.yaml later.

terraform {
  required_providers {
    aws = {
      source  = "hashicorp/aws"
      version = "4.9.0"
    }
  }

  backend "s3" {
    bucket = "acme-tfstate-9743d26d-5c66-4614-8a1f-417f86d2f958"
    key    = "tfstate"
    region = "eu-west-1"
  }
}

provider "aws" {
  region     = var.aws_region
  access_key = var.aws_access_key_id
  secret_key = var.aws_secret_key
}

When this is done, can initialize Terraform and see if all is well with the provider and backend:

terraform init

That’s it for now! To be continued on part 2.