Thumbnail for the Multi-Region IaC blog post
Multi-Region IaC
Published 2024-08-15

Disclaimer: This is not about setting up the same system in multiple regions for the purpose of disaster recovery or performance. This is for the cases where components of the same system need to be created in different regions.

Most often when we're building systems in AWS they are designed inside of a single region. There may be requirements that have you deploying that system to multiple regions but largely we're using regional services, or resources that we deploy across multiple Availability Zones in the same region. There are cases where we deviate from that norm though.

Say for example you want to replicate an S3 bucket to another in a second region to guard against a regional S3 outage, but otherwise have no disaster recovery needs at the larger system level. It may be convenient to create the replica bucket in the same IaC template for ease of maintenance and variable referencing at the IaC level.

Or, and this is the use case I see more often, you are going to use CloudFront with an application in a region other than us-east-1.

CloudFront is a global service so it doesn't matter which region you're in. However, there are a couple of nuances as you bring in other features and services:

  1. If you're going to put a CNAME in front of your distribution[1]And if it's user facing you most certainly will then you need to associate a certificate with it. That certificate must exist in AWS Certificate Manager (ACM)[2]You can also use the IAM certificate store. But lets be honest, nobody uses that.. Regardless of whether you provisioned the certificate in ACM or uploaded an existing certificate it must be in us-east-1 (Docs).
  2. Similarly, if you're going to associate the AWS WAF with your distribution, the WAF must be created in us-east-1 (Docs). If you've only ever done this through the console that may come as a surprise, it hides it from you, but behind the scenes that is what it's doing.

I really haven't seen many people go end-to-end with IaC to include creating the certificate, since you also have to perform the validation via DNS or email. I guess the juice just isn't worth the squeeze there. So using WAF with CloudFront is what we'll base our examples on.

I don't have a strong preference on the CloudFormation versus Terraform debate, and I'm not going to provide a rundown of all the pros/cons here. Google will surface a number of good articles on that. I will say that if the choice is up to me on a project I will go with Terraform, but I've had success with both across different projects. What I will cover here is why I prefer Terraform for dealing with this specific problem.


CloudFormation

When you use CloudFormation to provision resources based on a template, you interact with it within a region. If I do this:

aws cloudformation create-stack \
    --stack-name example-stack \
    --template-url s3://bucket/example-template.yaml

It will create the resources in the template in whatever region I happen to be in at the time, or rather what is specified via my configuration (environment variables, ~/.aws/config, etc.)

Alternatively I can specify the region:

aws cloudformation create-stack \
    --stack-name example-stack \
    --template-url s3://bucket/example-template.yaml \
    --region us-west-2

Which will ensure that the resources are provisioned in that region. Simple as it is, that's enough to solve the problem we have of needing to stand up the WAF in us-east-1 when we're provisioning the rest of our resources elsewhere.

So we split our resources into two stacks, one with the WAF that gets ran in us-east-1 and one with the rest of our infrastructure that gets ran in the target region, passing the required variables about the WAF from the first to the second.

aws cloudformation create-stack \
    --stack-name waf-stack \
    --template-url s3://bucket/waf-template.yaml \
    --region us-east-1

wafarn="$(aws cloudformation describe-stacks \
    --stack-name waf-stack \
    --query 'Stacks[0].Outputs[?OutputKey==`WafArn`].OutputValue' --output text
    --region us-east-1)"

aws cloudformation create-stack \
    --stack-name app-stack \
    --template-url s3://bucket/app-template.yaml \
    --parameters WafArn=$wafarn

The implementation may change based on the tools used in your build system, but the basic concept will stand. It really isn't that bad, I've seen worse workarounds[3]Like using CloudFront Functions to make CloudFront behave like existing web servers, or practically anything to do with Glue.. You could use export variables instead and prevent having to pass data between the stacks if you prefer to go that route.

However, I do think that Terraform offers a better solution to this problem.


Terraform

If you've ever used Terraform then I'm sure you're familiar with its concept of providers. After all, if you're using it with AWS you'll at minimum need the AWS provider. If you haven't used Terraform, providers are plugins that allow you to manage different types of resources. If you're using Azure then you'll need the Azure provider, if you're using GCP then you'll need the GCP provider. There are lots of different providers, and not just for the major cloud platforms.

Many people are use to using the default provider, but you can create multiple if you want by using aliases.

This can make our lives much easier, because now we can target multiple regions using multiple providers in the same module. In our provider configuration we'd have something like below (in main.tf if you're following Terraform conventions).

terraform {
    required_providers {
        aws = {
            source  = "hashicorp/aws"
            version = "5.62.0"
        }
    }
}
    
provider "aws" {}

provider "aws" {
    alias   = "waf_provider"
    region  = "us-east-1"
}

Then somewhere in your module you can stand up WAF and CloudFront:

resource "aws_wafv2_web_acl" "waf" {
    provider = aws.waf_provider
    
    name        = "some-waf-name"
    scope       = "CLOUDFRONT"
    
    # ...more configuration
}
    
resource "aws_cloudfront_distribution" "distribution" {
    comment             = "Distribution for example purposes"
    web_acl_id          = aws_wafv2_web_acl.waf.arn
    
    # ...more configuration
}

I tend to define my WAF and CloudFront configurations in the same file in the module because they're closely coupled[4]Some WAF rules may be globally applicable, but sometimes you need to define different rules for independent origin paths., but even if you define them in separate files they are now part of the same module, in sync, and you can directly reference them. All while abiding by the constraint that the WAF must be created in us-east-1.


The Bottom Line

The problem is solvable with both tools, so don't fret too much over it. Terraform offers a cleaner way but getting around it with CloudFormation is not a big lift. These types of scenarios crop up from time to time when dealing with AWS, but once you've solved it you typically don't have to touch it again.

Just make sure it's documented.