skip to content
alcher.dev

Lith Labs 010: Bootstrapping Terraform

/ 4 min read

Part of the Lith Labs series

Goal

The goal is to bootstrap an AWS environment using Terraform.

Setting up an AWS account

I won’t lay out the steps that I took as I deem it out of scope for this lab, but in general when creating an AWS account for a new project:

  1. Don’t reuse an existing AWS account. Create a fresh one to minimize tha chance of existing configuration interfering with the new resources.
  2. Once the root user is created, enable MFA and create an IAM admin user. This will be the only time you’d need to log in using the root user.
  3. Be sure to create budget to prevent surprising bills.

Once the account is set up, I created two sets of IAM credentials: one for my local development and one for Github Actions as secrets.

Bootstrapping the Terraform Resources

Terraform works by tracking your environment’s state in a state file. For any serious collaborative effort, this state should be hosted centrally and have state locking enabled. For AWS, this is done by creating an S3 bucket and a DynamoDB table for state storage and locking, respectively.

This poses a chicken-and-egg problem: how do we manage the S3 bucket and DynamoDB table through Terraform, if Terraform requires these resources to begin with?

There are a handful of options: ClickOps the resources, script it out with the AWS CLI, or use CloudFormation. But I find it intuitive to keep everything in Terraform and use the trussworks/bootstrap/aws module. In a nutshell, this will create the S3 bucket and DynamoDB table, and the ensuing state file is supposed to be stored in the repository. The non-bootstrapped Terraform resources will use the bootstraped resources as its backend, so no state file is necessary.

Of note is that the bootstrap Terraform code is a submodule. This is the directory structure:

terraform
|-- bootstrap
|   |-- locals.tf
|   |-- main.tf
|   |-- outputs.tf
|   |-- terraform.tfstate

Creating an EC2 instance

With the bootstrap step done, I can now create the actual AWS resources as the top-level module.

terraform
|-- bootstrap
|   |-- locals.tf
|   |-- main.tf
|   |-- outputs.tf
|   |-- terraform.tfstate
|-- locals.tf
|-- main.tf
|-- ec2.tf

Given that the purpose of the EC2 instance is to demonstrate that Terraform works, a very simple definition should suffice:

data "aws_ami" "ubuntu" {
  most_recent = true

  filter {
    name   = "name"
    values = ["ubuntu/images/hvm-ssd/ubuntu-jammy-22.04-amd64-server-*"]
  }

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

  owners = ["099720109477"] # Canonical
}

resource "aws_instance" "web_server" {
  ami           = data.aws_ami.ubuntu.id
  instance_type = "t3.micro"
}

Scripting the Terraform commands

Instead of manually typing the terraform commands in the Makefile, I created a handful of scripts to simplify the process (and get shell autocomplete). As an example, here’s a script that runs plan and apply:

#!/usr/bin/env bash

set -euo pipefail

ROOT_DIR=$(pwd)
TF_ROOT_DIR="${ROOT_DIR}/terraform"

cd "$TF_ROOT_DIR"
terraform apply -auto-approve

The scripts live under the Terraform directory:

terraform
|-- bootstrap
|   |-- locals.tf
|   |-- main.tf
|   |-- outputs.tf
|   |-- terraform.tfstate
|   `-- terraform.tfstate.backup
|-- ec2.tf
|-- locals.tf
|-- main.tf
|-- outputs.tf
`-- scripts
    |-- bootstrap.sh
    |-- destroy.sh
    |-- format.sh
    |-- init.sh
    |-- lint.sh
    `-- plan-and-apply.sh

Creating a Github Action on merge

With the scripts and the Makefile targets in place, creating the action is a straightforward procedure:

name: Deploy
on:
  push:
    branches: [ "master" ]
jobs:
  deploy:
    name: Run deploy command
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: hashicorp/setup-terraform@v3
        with:
          terraform_wrapper: false
      - 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: us-east-1
      - run: make deploy

We now have a deployment script runnable via local invocation and on merge to master!

Conclusion

In this lab, we bootstrap supporting resources that Terraform needs to function and create a basic EC2 instance to validate our Terraform usage.

The source is available at the feature/010-bootstrap-terraform branch.