技術(tech)

Terraform Template for AWS: Infrastructure as Code (IaC) Best Practices

Introduction

Managing infrastructure resources with code is a common goal.
When it comes time to use Terraform, you might find yourself wondering about the best directory structure and how to organize your workspaces.

In this article, I’ll share my personal best practices for a simple and efficient Terraform setup for small organizations.

Premises

This article is targeted at individuals or teams who fit the following criteria:

  • Want to manage AWS resources using Terraform
  • Have a small team (1-2 people) that can handle all infrastructure changes, where minor conflicts aren’t a major concern
  • Perfer to keep costs low, so instead of using Terraform Cloud, plan to manage the Terraform state using S3 on AWS.

On the other hand, this setup may not be suitable for larger organizations considering Terraform adoption.

Here are the reasons why:

  • In larger teams, frequent Terraform Apply operations can lead to conflicts aand unintended resource changes.
    • Currently, Terraform Apply can lock the state file on S3, but…
    • When CI/CD is set up, Terraform Apply is usually executed based on the state of the main branch.
    • If multiple member merge into the main branch, the risk of unintended changes increases.
    • In such cases, it’s better to move to Terraform Cloud for managing the state.

Overview

In conclusion, I recommend the following structure.
link: https://github.com/gonkunkun/terraform-template

Key points of this structure are as follows:

  • The terraform state file is not managed by Terraform itself, but by CloudFormation
    • This is a given.
      It is unacceptale to delete the Terraform state file from Terraformm itself.
  • The environment consists of two envs: staging and production.
  • Each environment’s state file is managed separately.

Do not separate if files for each env.

  • This is likely the most debatable point.
  • By not separating tf file for each environment, the following benefits arise:
    • Minimize the differences between stg and prd env.
    • If there are any environment-specific differences, they will be visible at a glance in the code.
    • Prevents double maintenance of code.
  • However, there are some drawbacks as well:
    • If there are changes that should only be applied to stg or prd, it requires extra effort.
    • Specifically, you will need to use conditional branching or count to apply changes only to one environment at a time.

Details

Directory Structure

The directory structure is as follows.

~/D/g/terraform-template ❯❯❯ tree .                                                                                                                                                                                               main
.
├── README.md
├── cloudformation
│   └── S3.yaml
└── workloads
    ├── data.tf
    ├── envs
    │   ├── prd
    │   │   ├── backend.hcl
    │   │   └── terraform.tfvars
    │   └── stg
    │       ├── backend.hcl
    │       └── terraform.tfvars
    ├── locals.tf
    ├── main.tf
    ├── providers.tf
    ├── terraform.tfvars
    ├── variables.tf
    ├── versions.tf
    └── vpc.tf

Let’s now look at some key points.

Location of State Files.

Ideally, I would like to manage this setting via CI/CD, but this hasn’t been considered at this stage.
After configuring the bucket name and a user with Terraform permissions, we will create the S3 bucket using CloufFormation.

Resources:
  StateBucketStg:
    Type: "AWS::S3::Bucket"
    Properties:
      BucketName: "xxxxx-terraform-state-stg"
      VersioningConfiguration:
        Status: Enabled
      PublicAccessBlockConfiguration:
        BlockPublicAcls: true
        BlockPublicPolicy: true
        IgnorePublicAcls: true
        RestrictPublicBuckets: true
      BucketEncryption:
        ServerSideEncryptionConfiguration:
          - ServerSideEncryptionByDefault:
              SSEAlgorithm: AES256

  StateFileBucketPolicyForStg:
    Type: "AWS::S3::BucketPolicy"
    Properties:
      Bucket: !Ref StateBucketStg
      PolicyDocument:
        Version: "2012-10-17"
        Statement:
          - Sid: "AllowSpecificIAMUser"
            Effect: "Allow"
            Principal:
              AWS: "arn:aws:iam::xxxxxxxxx:user/terraform"
            Action:
              - "s3:GetObject"
              - "s3:PutObject"
              - "s3:ListBucket"
              - "s3:DeleteObject"
            Resource:
              - !Sub "arn:aws:s3:::${StateBucketStg}/*"
              - !Sub "arn:aws:s3:::${StateBucketStg}"

  TerraformPlanOnlyPolicyForStg:
    Type: "AWS::IAM::Policy"
    Properties:
      PolicyName: "TerraformPlanOnlyPolicy"
      Roles:
        - !Ref TerraformUserRole 
      PolicyDocument:
        Version: "2012-10-17"
        Statement:
          - Sid: "AllowPlanActions"
            Effect: "Allow"
            Action:
              - "s3:GetObject"
              - "s3:ListBucket"
            Resource:
              - !Sub "arn:aws:s3:::${StateBucket}"
              - !Sub "arn:aws:s3:::${StateBucket}/*"

          - Sid: "DenyApplyAndDestroy"
            Effect: "Deny"
            Action:
              - "s3:PutObject"
              - "s3:DeleteObject"
            Resource:
              - !Sub "arn:aws:s3:::${StateBucket}"
              - !Sub "arn:aws:s3:::${StateBucket}/*"

  StateBucketPrd:
    Type: "AWS::S3::Bucket"
    Properties:
      BucketName: "xxxxx-terraform-state-prd"
      VersioningConfiguration:
        Status: Enabled
      PublicAccessBlockConfiguration:
        BlockPublicAcls: true
        BlockPublicPolicy: true
        IgnorePublicAcls: true
        RestrictPublicBuckets: true
      BucketEncryption:
        ServerSideEncryptionConfiguration:
          - ServerSideEncryptionByDefault:
              SSEAlgorithm: AES256

  StateFileBucketPolicyForPrd:
    Type: "AWS::S3::BucketPolicy"
    Properties:
      Bucket: !Ref StateBucketPrd
      PolicyDocument:
        Version: "2012-10-17"
        Statement:
          - Sid: "AllowSpecificIAMUser"
            Effect: "Allow"
            Principal:
              AWS: "arn:aws:iam::xxxxxxxxx:user/terraform"
            Action:
              - "s3:GetObject"
              - "s3:PutObject"
              - "s3:ListBucket"
              - "s3:DeleteObject"
            Resource:
              - !Sub "arn:aws:s3:::${StateBucketPrd}/*"
              - !Sub "arn:aws:s3:::${StateBucketPrd}"

ref: https://github.com/gonkunkun/terraform-template/blob/main/cloudformation/S3.yaml

Backend Configuration (State File Storage Location)

The backend configuration is specified for each environment.

bucket         = "xxxxx-terraform-state-stg"
region         = "ap-northeast-1"
key            = "stg/terraform.tfstate"
use_lockfile   = true
encrypt        = true

stg: https://github.com/gonkunkun/terraform-template/blob/main/workloads/envs/stg/backend.hcl

bucket         = "xxxxx-terraform-state-prd"
region         = "ap-northeast-1"
key            = "prd/terraform.tfstate"
use_lockfile   = true
encrypt        = true

prd: https://github.com/gonkunkun/terraform-template/blob/main/workloads/envs/prd/backend.hcl

It’s great that the need for state locking with DynamoDB has been removed.
ref: https://github.com/hashicorp/terraform/blob/5279e432611752a9bcba27baaf657a3305c226f8/website/docs/language/backend/s3.mdx#state-locking

By the way, when running plan or apply, the command will be as follows.

# In the case of stg
## Specify the backend
terraform init -backend-config="envs/stg/backend.hcl"
## Plan
terraform plan -var="env=stg"

# In the case of prd
## Specify the backend
terraform init -backend-config="envs/prd/backend.hcl"
## Plan
terraform plan -var="env=prd"

Defining Resources for stg and prd Env

Define variables for each environment and pass the to the Terraform resources.

locals {
  cidr_block = {
    stg : "10.10.0.0/16",
    prd : "10.20.0.0/16",
  }[var.env]
}

#trivy:ignore:AVD-AWS-0178
resource "aws_vpc" "sample" {
  cidr_block = local.cidr_block

  tags = {
    "Name" = "sample-${var.env}-vpc"
  }
  tags_all = {
    "Name" = "sample-${var.env}-vpc"
  }

  lifecycle {
    prevent_destroy = true
  }
}

ref: https://github.com/gonkunkun/terraform-template/blob/main/workloads/vpc.tf

Setup (Up to Terraform Plan)

Now, let’s go over the setup process to be able to run plan from your local environment.

Prerequisites

Plese complete the following tasks in advance:

  • Create the target AWS account
  • Create the S3 bucket for state management
  • Create an AIM user or role for Terraform exectuon/ Configure the profile on your local environment

Configurations

Let’s clone the repository.

# Install Terraform (using brew)
brew install terraform

# Clone the repository
git clone https://github.com/gonkunkun/terraform-template

Set the correct backend S3 bucket name.
stg: https://github.com/gonkunkun/terraform-template/blob/main/workloads/envs/stg/backend.hcl
prd: https://github.com/gonkunkun/terraform-template/blob/main/workloads/envs/prd/backend.hcl

Set the region according to your environment.
https://github.com/gonkunkun/terraform-template/blob/main/workloads/providers.tf

Select the backend and initialize.
(This should generate the state file on S3 at this point.)

# Initialize the provider (for the staging environment)
cd ./terraform-template/workloads
terraform init -backend-config="envs/stg/backend.hcl"

Then, confirm that you can run plan.

terraform plan -var="env=stg"

Conclusion

In this article, we covered the directory structure and initial setup for Terraform.

I’m sure you might also be interested in CI/CD setup, so I will cover that in a separate article.

Implementing Terraform CI/CD with GitHub ActionsIntroduction When managing infrastructure with Terraform, setting up...

Thank you for reading this far.