Part 2 — Provisioning & Deploying the Multi-Region AWS Stack with Terraform and GitLab CI/CD
How to provision globally replicated AWS S3 buckets, a Multi-Region Access Point (MRAP), native CloudFront SigV4a Origin Access Control (OAC), ACM SSL, and Route 53 DNS using Terraform 1.10+ and GitLab CI/CD.
In late 2025, I wrote two posts about hosting this site across multiple AWSAWSAmazon Web ServicesCloudAmazon global cloud computing platform offering on-demand compute, object storage, content delivery, and managed infrastructure services across dozens of geographic regions. regions using an S3S3Simple Storage ServiceCloudAmazon Web Services scalable object storage offering 99.999999999% (11 9s) of data durability with fine-grained IAM policies and versioning. Multi-Region Access Point (MRAPMRAPMulti-Region Access PointCloudAn Amazon S3 global endpoint that dynamically routes client requests across multiple AWS regions using AWS Global Accelerator for lowest latency.) behind CloudFrontCloudFrontAmazon CloudFrontCloudAWS global content delivery network (CDN) that securely delivers data, static assets, and APIs at low latency with edge caching and Origin Access Control. (manual setup and initial Terraform configuration). Back then, connecting CloudFront to an S3 MRAP required deploying a custom Python Lambda@Edge function in us-east-1 just to re-sign origin requests with SigV4a.
Fast-forward to today, and the infrastructure inside this repository’s terraform/ directory is dramatically cleaner, faster, and cheaper to operate. Three major platform improvements allowed us to delete hundreds of lines of boilerplate:
- Native CloudFront SigV4a Origin Access Control (OACOACOrigin Access ControlSecurityAWS CloudFront security mechanism that signs viewer requests with SigV4 to restrict Amazon S3 bucket access strictly to authorized CloudFront distributions.): CloudFront now supports
signing_protocol = "sigv4a"natively on S3 Multi-Region Access Point origins.Lambda@Edge—along with its cold starts, IAMIAMIdentity & Access ManagementSecurityA security framework of role-based and attribute-based policies controlling which identities (users, services, or roles) can perform actions on cloud resources. execution roles, and packaging scripts—is completely gone. - Sub-Millisecond CloudFront Function at
viewer-request: A lightweight CloudFront Function handles both naked-to-www301redirects (example.com→https://www.example.com) and static directory index rewrites (/about/→/about/index.html) right at the edge before requests ever touch the origin. - TerraformTerraformHashiCorp TerraformDevOpsAn open-source Infrastructure as Code (IaC) tool that provisions, manages, and versions multi-cloud resources declaratively via HashiCorp Configuration Language.
>= 1.10Native S3 Locking + AWS Provider~> 6.0Per-Resource Region: Remote state locking uses native S3.tflocklockfiles (use_lockfile = true, no DynamoDB table required), and multi-region S3 buckets use the AWS Provider 6.0 per-resourceregionattribute instead of dozens of duplicated provider aliases.
Here is the complete end-to-end guide to provisioning this stack for your own domain—either through the automated GitLab CI/CDCI/CDContinuous Integration & Continuous DeliveryDevOpsDevOps automation pipeline practice that automatically compiles, tests, and deploys software changes directly to staging or production environments. pipeline or from your local terminal.
Multi-Region Request & Replication Topology
When deployed, your static site is replicated across your chosen AWS regions and served globally from the nearest healthy S3 bucket behind CloudFront:
Prerequisites
Before running terraform:bootstrap or terraform:apply, make sure you have the following in place:
- An Active AWS Account:
- Standard AWS account with default regions enabled. (Note: CloudFront OAC for S3 Multi-Region Access Points supports all 17 default-enabled AWS regions; opt-in regions are not supported by AWS for MRAP OAC.)
- A Registered Domain Hosted on Amazon Route 53:
- Your root domain (e.g.
example.com) must already exist as a Public Hosted Zone in Route 53 (whether registered directly through Route 53 or an external registrar). - Verify Nameservers (
NS) First: If your domain was registered outside AWS, ensure itsNSrecords at your registrar already point to the four Route 53 nameservers listed in your Hosted Zone. Duringterraform apply,aws_acm_certificate_validationwaits for public DNSDNSDomain Name SystemNetworkingThe distributed hierarchical naming system that translates human-readable hostnames into numerical IP addresses and directs mail routing via MX records. validation of the ACMACMAWS Certificate ManagerSecurityAWS service that provisions, manages, and automatically renews public and private SSL/TLS certificates for CloudFront, ALBs, and API Gateways. TLSTLSTransport Layer SecuritySecurityCryptographic protocol operating above transport protocols to provide mutual authentication, confidentiality, and data integrity over networks. certificate (example.comand*.example.cominus-east-1); if your domain’s nameservers don’t delegate to Route 53 yet, certificate validation will wait until they do.
- Your root domain (e.g.
- AWS IAM Credentials (
AWS_ACCESS_KEY_ID&AWS_SECRET_ACCESS_KEY):- An IAM User or CI/CD role with permissions to provision and manage:
- S3 & S3 Control: Regional buckets, versioning, encryption, lifecycle rules, replication, and Multi-Region Access Points (
s3:*,s3control:*) - CloudFront: Distributions, SigV4a Origin Access Control (OAC), and CloudFront Functions (
cloudfront:*) - ACM (
us-east-1): Public SSL/TLS certificate creation and DNS validation (acm:*) - Route 53: Hosted Zone lookup plus ACM validation
CNAMEandA(IPv4IPv4Internet Protocol Version 4NetworkingThe fourth revision of the Internet Protocol using 32-bit addresses (e.g. 192.0.2.1), routed via DNS A records across packet-switched networks.) /AAAA(IPv6IPv6Internet Protocol Version 6NetworkingThe successor Internet Protocol standard utilizing 128-bit hexadecimal addresses (routed via DNS AAAA records) to solve IPv4 address exhaustion.) Alias records (route53:*) - IAM: Creating and attaching the least-privilege S3 Cross-Region Replication role (
iam:CreateRole,iam:CreatePolicy,iam:AttachRolePolicy,iam:PassRole)
- S3 & S3 Control: Regional buckets, versioning, encryption, lifecycle rules, replication, and Multi-Region Access Points (
- An IAM User or CI/CD role with permissions to provision and manage:
- A GitLab Account (Free Tier) — or Local CLICLICommand-Line InterfaceDevOpsA text-based user interface mechanism that processes terminal commands to interact directly with programs, operating systems, and cloud APIs. Tools:
- Option A (GitLab CI/CD): A free GitLab account hosting your forked repository to run the built-in
.gitlab-ci.ymlpipeline. - Option B (Local Terminal): Terraform
>= 1.10.0, AWS CLI v2, and Node.js>= 20installed on your workstation.
- Option A (GitLab CI/CD): A free GitLab account hosting your forked repository to run the built-in
- Your Site Domain Configured in
src/site.config.ts(Part 1):- Ensure
siteUrlanddomaininsrc/site.config.tsmatch your Route 53 domain so build-time canonical links, sitemaps, feeds, and Open Graph cards match your live infrastructure.
- Ensure
Step 1: Select Your Active Regions in terraform/variables.tf
Open terraform/variables.tf and inspect variable "buckets". By default, all 17 default-enabled AWS regions supported by S3 MRAP + CloudFront OAC are pre-listed so you can simply comment out (#) the regions you don’t need:
variable "buckets" {
description = "List of regional S3 buckets behind the Multi-Region Access Point (MRAP)."
type = list(object({
region = string
is_main = bool
priority = optional(number)
}))
default = [
# --- Primary Region (Deploy Target) ---
{ region = "ap-northeast-1", is_main = true }, # Asia Pacific (Tokyo)
# --- Europe ---
{ region = "eu-north-1", is_main = false }, # Europe (Stockholm)
{ region = "eu-west-1", is_main = false }, # Europe (Ireland)
# --- Americas ---
{ region = "us-east-1", is_main = false }, # US East (N. Virginia)
{ region = "us-west-2", is_main = false }, # US West (Oregon)
]
}
Step 2: Provision Infrastructure (Option A — GitLab CI/CD)
The repository root .gitlab-ci.yml runs on two independent tracks so normal content commits never block on manual infrastructure jobs:
1. Add Initial Bootstrap & Provisioning Variables
On a brand-new setup, your S3 buckets and CloudFront distribution do not exist yet—so you cannot set S3_BUCKET or CLOUDFRONT_DISTRIBUTION_ID upfront. Start by opening Settings → CI/CD → Variables in your GitLab project and adding only the credentials and domain needed to bootstrap remote state and run Terraform:
| Variable Key | Required? | Example / Default | Purpose |
|---|---|---|---|
AWS_ACCESS_KEY_ID | Required | AKIA... | AWS IAM Access Key ID for Terraform & S3 deploys (Protected, Masked) |
AWS_SECRET_ACCESS_KEY | Required | wJalrXUtnFEMI/... | AWS IAM Secret Access Key (Protected, Masked) |
TF_VAR_environment_domain | Required | example.com | Primary root domain in Route 53; auto-derives <domain-slug>-tfstate |
AWS_DEFAULT_REGION | Optional | ap-northeast-1 (default) | Default AWS CLI region and fallback region for the <domain-slug>-tfstate bucket |
TF_VAR_environment_domain_slug | Optional | example-com (dots → hyphens) | Custom prefix override for state bucket and main stack resource names |
TF_STATE_BUCKET / TF_STATE_REGION | Optional | <domain-slug>-tfstate / AWS_DEFAULT_REGION | Explicit overrides if you want the Terraform state bucket named or hosted separately |
2. Bootstrap Remote State (terraform:bootstrap — One-Time Only)
terraform/bootstrap/ is a self-contained Terraform root module that does not depend on terraform/variables.tf—it only needs the initial variables above to provision the remote state backend.
In GitLab Build → Pipelines on main, click the play button (▶) on terraform:bootstrap. This creates the dedicated, versioned, SSE-S3 encrypted, TLS-enforced <domain-slug>-tfstate bucket (which also stores native Terraform >= 1.10 .tflock lockfiles).
3. Plan & Apply the Multi-Region Stack (terraform:plan → terraform:apply)
- Trigger
terraform:plan(or push a change toterraform/variables.tf). Terraform initializes against your newly bootstrapped<domain-slug>-tfstatebucket, validates the configuration across your selected regions invar.buckets, and saves thetfplanartifact. - Review the planned resources in the job log, then click play (▶) on
terraform:applyto provision the regional S3 buckets, cross-region replication IAM & rules, S3 Multi-Region Access Point,us-east-1ACM certificate, CloudFront distribution, and Route 53A/AAAArecords. - At the end of the
terraform:applyjob log, Terraform prints your newly created resource identifiers:Outputs: active_bucket_regions = [ "ap-northeast-1", "eu-north-1", "eu-west-1", "us-east-1", "us-west-2" ] cloudfront_distribution_id = "E1A2B3C4D5E6F7" primary_upload_bucket = "example-com-ap-northeast-1" primary_upload_region = "ap-northeast-1" site_urls = [ "https://www.example.com", "https://example.com" ]
4. Add Deployment Target Variables & Run deploytos3
Now that your primary S3 upload bucket and CloudFront distribution exist, return to Settings → CI/CD → Variables and add the two post-provisioning variables from the terraform:apply output:
| Variable Key | Value Source (terraform:apply output) | Purpose |
|---|---|---|
S3_BUCKET | primary_upload_bucket (e.g. example-com-ap-northeast-1) | Primary S3 bucket where scripts/s3-deploy.sh uploads ./dist |
CLOUDFRONT_DISTRIBUTION_ID | cloudfront_distribution_id (e.g. E1A2B3C4D5E6F7) | CloudFront distribution invalidated after S3 delta sync |
Finally, click retry/play (▶) on deploytos3 in your pipeline (or push a commit to main) to sync ./dist to your primary S3 bucket and issue a targeted CloudFront cache invalidation.
Step 3: Provision Infrastructure (Option B — Local CLI)
If you prefer provisioning from your workstation terminal instead of GitLab CI:
# 1. Create the dedicated S3 state bucket (one-time bootstrap)
cd terraform/bootstrap
terraform init
terraform apply -var="environment_domain=example.com"
# 2. Initialize and apply the main multi-region stack
cd ..
cp terraform.tfvars.example terraform.tfvars
# Edit terraform.tfvars to set: environment_domain = "example.com"
terraform init \
-backend-config="bucket=example-com-tfstate" \
-backend-config="region=ap-northeast-1"
terraform apply
Once terraform apply finishes, you can deploy ./dist directly from your machine at any time using scripts/s3-deploy.sh:
npm run build
S3_BUCKET="$(terraform -chdir=terraform output -raw primary_upload_bucket)" \
CLOUDFRONT_DISTRIBUTION_ID="$(terraform -chdir=terraform output -raw cloudfront_distribution_id)" \
bash ./scripts/s3-deploy.sh ./dist
Under the Hood: Key Terraform Resources
If you are curious how the pieces fit together inside terraform/, here are the three core building blocks:
1. Native SigV4a Origin Access Control (terraform/cloudfront.tf)
Instead of routing origin requests through a Lambda@Edge signer, aws_cloudfront_origin_access_control now speaks sigv4a directly to the MRAP endpoint. Note one critical CloudFront detail: because an MRAP hostname ends in .accesspoint.s3-global.amazonaws.com rather than .s3.<region>.amazonaws.com, the distribution’s origin block must include custom_origin_config alongside origin_access_control_id (omitting custom_origin_config causes Terraform to send S3OriginConfig, which CloudFront rejects with The parameter Origin DomainName does not refer to a valid S3 bucket):
resource "aws_cloudfront_origin_access_control" "mrap_oac" {
name = "${local.domain_slug}-mrap-oac"
description = "Native SigV4a Origin Access Control for S3 Multi-Region Access Point"
origin_access_control_origin_type = "s3"
signing_behavior = "always"
signing_protocol = "sigv4a"
}
# Inside aws_cloudfront_distribution.mrap_distribution:
origin {
domain_name = aws_s3control_multi_region_access_point.mrap.domain_name
origin_id = "mrap_origin_id"
origin_access_control_id = aws_cloudfront_origin_access_control.mrap_oac.id
custom_origin_config {
http_port = 80
https_port = 443
origin_protocol_policy = "https-only"
origin_ssl_protocols = ["TLSv1.2"]
}
}
Both each regional S3 bucket policy and the Multi-Region Access Point policy lock down s3:GetObject strictly to cloudfront.amazonaws.com scoped to your AWS:SourceArn CloudFront distribution ARNARNAmazon Resource NameCloudStandardized global identifiers that uniquely reference individual AWS resources across accounts, services, and regions (e.g. arn:aws:s3:::bucket).. All S3 buckets and the MRAP itself have block_public_acls, block_public_policy, ignore_public_acls, and restrict_public_buckets enabled.
2. Apex Redirect & Directory Index Rewriting (terraform/cloudfront.tf)
Because an S3 Multi-Region Access Point uses the S3 REST APIAPIApplication Programming InterfaceDevOpsA formal contract of endpoints, protocols, and data schemas that enables distinct software services to exchange commands and state predictably. endpoint rather than the legacy S3 Website endpoint, requests for /about/ do not automatically resolve to /about/index.html on the S3 side. Our viewer-request CloudFront Function handles both canonical host redirection and directory index resolution at the edge:
function handler(event) {
var request = event.request;
var host = request.headers.host ? request.headers.host.value : '';
// 1. Canonical redirect: naked domain -> www
if (host === 'example.com') {
return {
statusCode: 301,
statusDescription: 'Moved Permanently',
headers: {
location: { value: 'https://www.example.com' + request.uri }
}
};
}
// 2. Static directory index rewrite
var uri = request.uri;
if (uri.endsWith('/')) {
request.uri += 'index.html';
} else if (!uri.includes('.')) {
request.uri += '/index.html';
}
return request;
}
3. Delta-Only S3 Sync & Targeted Invalidations (scripts/s3-deploy.sh)
As detailed in our deep-dive on optimizing GitLab CI S3 deployments, standard aws s3 sync re-uploads every file after a static site build because freshly generated files have newer mtime timestamps.
Our deployment script (scripts/s3-deploy.sh) queries existing S3 object ETags via aws s3api list-objects-v2, compares them against local MD5 hashes, uploads only files whose content actually changed, and submits a targeted CloudFront invalidation (collapsing /about/index.html to /about/* and switching to a single /* wildcard if more than 15 paths changed).
Wrapping Up
Between Part 1 (Theme & Content Customization) and Part 2 (Multi-Region AWS Provisioning & CI/CD), you can take this repository from a fresh git clone to a globally replicated, zero-Lambda static site with automated Open Graph cards, build-time Mermaid diagrams, interactive technical tooltips, and 10-second delta deployments.

About the author
Ed Stuurman
Infrastructure and reliability engineer based in Japan. Passionate about resilient systems, bare-metal hardware, cloud automation with Terraform, and all things homelab.
Contact
Thoughts or questions on this article?
Found a bug, tested a similar setup, or want to discuss the details? Drop a message below.