Managing Terraform Remote State with CloudFormation

Chris / February 24, 2021 in 

We previously covered several limitations of Terragrunt managing the creation of the state bucket, log bucket, and lock table used for storing Terraform remote state in AWS. For instance, we often want full control over encryption properties, and Terragrunt does not allow you to specify things like custom encryption keys.

These limitations often drive our team to eschew Terragrunt’s management of the operational infrastructure Terraform requires. Many of our teams have found CloudFormation to be an attractive alternative for managing this operational infrastructure.

Chris, BTI360 engineer and author of thirstydeveloper.io, will show us how to get started using CloudFormation to bootstrap an account for Terraform and Terragrunt.

Terraform State with CloudFormation

We’ll start with a CloudFormation template that exactly mirrors what Terragrunt creates for you so that:

  1. You can use this template to import resources Terragrunt has already created for you
  2. We can discuss ways to harden beyond what Terragrunt does for you

Assuming you have Terragrunt’s remote_state configured as follows:

locals {
  root_deployments_dir       = get_parent_terragrunt_dir()
  relative_deployment_path   = path_relative_to_include()
  deployment_path_components = compact(split("/", local.relative_deployment_path))

  tier  = local.deployment_path_components[0]
  stack = reverse(local.deployment_path_components)[0]
}

remote_state {
  backend = "s3"
  generate = {
    path      = "backend.tf"
    if_exists = "overwrite"
  }
  config = {
    bucket  = "bti360-terraform-state"
    region  = "us-east-1"
    encrypt = true

    key = "${dirname(local.relative_deployment_path)}/${local.stack}.tfstate"

    dynamodb_table            = "bti360-terraform-state-locks"
    accesslogging_bucket_name = "bti360-terraform-state-logs"
  }
}

and assuming you’re using Terragrunt 0.26.4, here’s a CloudFormation template that matches what Terragrunt creates with the above configuration:

---
AWSTemplateFormatVersion: '2010-09-09'
Description: Deploy terraform operational infrastructure
Metadata:
  AWS::CloudFormation::Interface:
    ParameterGroups:
      - Label:
          default: Terraform State Resources
        Parameters:
          - StateBucketName
          - StateLogBucketName
          - LockTableName
Parameters:
  StateBucketName:
    Type: String
    Description: Name of the S3 bucket for terraform state
  StateLogBucketName:
    Type: String
    Description: Name of the S3 bucket for terraform state logs
  LockTableName:
    Type: String
    Description: Name of the terraform DynamoDB lock table

Resources:
  TerraformStateLogBucket:
    Type: 'AWS::S3::Bucket'
    DeletionPolicy: Retain
    UpdateReplacePolicy: Retain
    Properties:
      BucketName: !Ref StateLogBucketName
      AccessControl: LogDeliveryWrite

  TerraformStateBucket:
    Type: 'AWS::S3::Bucket'
    DeletionPolicy: Retain
    UpdateReplacePolicy: Retain
    Properties:
      BucketName: !Ref StateBucketName
      BucketEncryption:
        ServerSideEncryptionConfiguration:
          - ServerSideEncryptionByDefault:
              SSEAlgorithm: aws:kms
      LoggingConfiguration:
        DestinationBucketName: !Ref StateLogBucketName
        LogFilePrefix: TFStateLogs/
      PublicAccessBlockConfiguration:
        BlockPublicAcls: True
        BlockPublicPolicy: True
        IgnorePublicAcls: True
        RestrictPublicBuckets: True
      VersioningConfiguration:
        Status: Enabled

  TerraformStateLockTable:
    Type: 'AWS::DynamoDB::Table'
    DeletionPolicy: Retain
    UpdateReplacePolicy: Retain
    Properties:
      TableName: !Ref LockTableName
      AttributeDefinitions:
        - AttributeName: LockID
          AttributeType: S
      KeySchema:
        - AttributeName: LockID
          KeyType: HASH
      BillingMode: PAY_PER_REQUEST

Put this YAML into a terraform-bootstrap.cf.yml file. For today’s post, we’re going to assume you’re working from a clean slate. If you already have a state bucket, log bucket, or lock table that Terragrunt created for you, you can use CloudFormation’s resource import feature to bring those under CloudFormation’s control. thirstydeveloper.io has a step-by-step guide on importing Terragrunt created resources into CloudFormation.

Assuming you’re starting fresh, run the following to deploy your CloudFormation stack:

aws --profile bti360 cloudformation deploy \
  --template-file terraform-bootstrap.cf.yml
  --stack-name terraform-bootstrap \
  --capabilities CAPABILITY_NAMED_IAM \
  --parameter-overrides \
    StateBucketName=bti360-terraform-state \
    StateLogBucketName=bti360-terraform-state-logs \
    LockTableName=bti360-terraform-state-locks

Once the stack finishes deployment, you can run terragrunt commands with the above remote_state configuration, and Terragrunt will use the state bucket, log bucket, and lock table CloudFormation created.

Hardening Opportunities

With Terraform’s operational infrastructure managed by CloudFormation, let’s discuss ways to improve Terragrunt’s defaults.

Here are four specific hardening opportunities our teams often exercise:

1. Encryption on the logs bucket
Terragrunt sadly does not enable encryption on the logs bucket by default. We can remedy this in CloudFormation by specifying the BucketEncryption property, just like we do on the state bucket.

2. Block public access on the logs bucket
Similarly, the logs bucket does not explicitly block public access. We can do that with a PublicAccessBlockConfiguration property, again mirroring our state bucket.

3. Encrypt the lock table
We can enable encryption on the DynamoDB lock table using the SSESpecification property.

4. Restrict access to the state bucket
We can use an AWS::S3::BucketPolicy resource to lock down which IAM principals can access our Terraform state. Implementing access control around Terraform state is an excellent idea considering (1) the state almost always contains sensitive information, and (2) losing state can cause tremendous pain to import existing resources back under Terraform’s control.

Conclusion

While we use Terraform for the vast majority of our infrastructure-as-code needs at BTI360, we’ve found CloudFormation quite useful for bootstrapping an AWS account for Terraform management. We heartily recommend CloudFormation if your team has bumped into the limitations of letting Terragrunt manage the creation of Terraform’s operational infrastructure. For more information on this approach, including a fully worked example, see part 5 of Chris’ terraform-skeleton series on thirstydeveloper.io.

 


Interested in Solving Challenging Problems? Work Here!

Are you a software engineer, interested in joining a software company that invests in its teammates and promotes a strong engineering culture? Then you’re in the right place! Check out our current Career Opportunities. We’re always looking for like-minded engineers to join the BTI360 family.


Related Posts:

Previous

Advantages and Limitations of Terragrunt-Managed Backends

Next

Terraform Backend: Role-Based Access Control – Part 1

Close Form

Enjoy our Blog?

Then stay up-to-date with our latest posts delivered right to your inbox.

  • This field is for validation purposes and should be left unchanged.

Or catch us on social media

Stay in Touch

Whether we’re honing our craft, hanging out with our team, or volunteering in the community, we invite you to keep tabs on us.

  • This field is for validation purposes and should be left unchanged.