Part 2 (Optional) — Provisioning the Multi-Region AWS Stack with Terraform (GitLab CI, GitHub Actions & Local CLI)
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+ via GitLab CI/CD, GitHub Actions, or Local CLI.
This is Part 2 (Optional) of a three-part setup and deployment guide for The Navigator (GitHub · GitLab · Live Demo):
- Part 1 — Customizing This Astro Theme: Local setup, identity & theme configuration, and content authoring.
- Part 2 (Optional — You are here): Provisioning the multi-region AWSAWSAmazon Web ServicesCloudAmazon global cloud computing platform offering on-demand compute, object storage, content delivery, and managed infrastructure services across dozens of geographic regions. S3S3Simple Storage ServiceCloudAmazon Web Services scalable object storage offering 99.999999999% (11 9s) of data durability with fine-grained IAM policies and versioning. MRAPMRAPMulti-Region Access PointCloudAn Amazon S3 global endpoint that dynamically routes client requests across multiple AWS regions using AWS Global Accelerator for lowest latency. + 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. SigV4a stack with TerraformTerraformHashiCorp TerraformDevOpsAn open-source Infrastructure as Code (IaC) tool that provisions, manages, and versions multi-cloud resources declaratively via HashiCorp Configuration Language..
- Part 3 — Automated CI/CD Pipelines, Starter Dry-Run Mode & Deploying to Any Static Host: Zero-config deployment to Vercel/Netlify/Cloudflare Pages, dual GitHub Actions & GitLab CI dry-run pipelines, and two-tier S3/CloudFront delta sync.
⏱️ Automated Provisioning — Expect ~20–30 Minutes for First-Time Greenfield Creation: While the entire infrastructure stack is automated via Terraform, provisioning from zero for the first time typically takes 20 to 30 minutes end-to-end inside AWS:
- 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 DNSDNSDomain Name SystemNetworkingThe distributed hierarchical naming system that translates human-readable hostnames into numerical IP addresses and directs mail routing via MX records. Validation (
us-east-1): ~2–5 minutes - S3 Multi-Region Access Point (
aws_s3control_multi_region_access_point): ~10–20 minutes (AWS provisions global anycast routing tables and cross-region control plane endpoints behind the scenes) - CloudFront Global Edge Distribution + MRAP Policy Attachment: ~5–10 minutes
Grab a coffee during your initial terraform apply—subsequent runs and site deployments (scripts/s3-deploy.sh) complete in ~10–15 seconds.
The terraform/ directory provisions an optional, enterprise-grade multi-region AWS static hosting stack built around four core capabilities:
- 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 signs requests directly to the S3 Multi-Region Access Point (MRAP) origin using
signing_protocol = "sigv4a", requiring zeroLambda@Edgefunctions. - CloudFront Function (
viewer-request): Handles apex-to-www301redirects (example.com→https://www.example.com) and static directory index rewrites (/about/→/about/index.html) at the edge. - Managed Security Response Headers (
Managed-SecurityHeadersPolicy): Attaches AWS’s managed security headers policy (Strict-Transport-Security,X-Content-Type-Options: nosniff,X-Frame-Options: SAMEORIGIN,Referrer-Policy, andX-XSS-Protection) at the CloudFront edge. - Terraform
>= 1.10Native S3 State Locking & AWS Provider~> 6.0: Remote state locking uses native S3.tflocklockfiles (use_lockfile = truewithout DynamoDB), and multi-region S3 buckets use the AWS Provider 6.0 per-resourceregionattribute.
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:
Choose Your Deployment Path
You can provision and manage this Terraform stack using whichever workflow fits your setup:
| Path | Best For | How It Runs |
|---|---|---|
| Option A — GitLab CI/CD | Teams & individuals hosting their repo on GitLab | Built-in .gitlab-ci.yml manual jobs (terraform:bootstrap → terraform:plan → terraform:apply) |
| Option B — GitHub Actions | Teams & individuals hosting their repo on GitHub | One-time terraform/bootstrap initialization + .github/workflows/deploy.yml automated builds & S3 deploys |
| Option C — Local CLI | Engineers who prefer running Terraform & deployments directly from their workstation | Local terraform init && terraform apply + bash ./scripts/s3-deploy.sh ./dist |
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 53Route 53Amazon Route 53CloudAWS highly available, authoritative cloud Domain Name System (DNS) service supporting latency-based routing, health checks, and CloudFront alias records.:
- 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 DNS validation of the ACM TLS 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 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. Credentials (
AWS_ACCESS_KEY_ID&AWS_SECRET_ACCESS_KEY):- An IAM User or CI/CDCI/CDContinuous Integration & Continuous DeliveryDevOpsDevOps automation pipeline practice that automatically compiles, tests, and deploys software changes directly to staging or production environments. 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/CDCI/CDContinuous Integration & Continuous DeliveryDevOpsDevOps automation pipeline practice that automatically compiles, tests, and deploys software changes directly to staging or production environments. role with permissions to provision and manage:
- Your Site Domain Configured in
src/site.config.ts(Part 1):- Ensure
site.urlandsite.domaininsrc/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 & Domain (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)
]
}
Choose Your Regions Before First Apply: In AWS, S3 Multi-Region Access Point bucket membership is immutable after creation. Select the regions you want in terraform/variables.tf before running your initial terraform apply. Exactly one bucket must have is_main = true—this is the primary bucket where your CI/CD pipeline uploads build artifacts, which S3 Cross-Region Replication then fans out to all replica regions.
Setting environment_domain (Naked Root Domain)
Whether you pass environment_domain via terraform/terraform.tfvars (environment_domain = "example.com") or CI/CD variables (TF_VAR_environment_domain = example.com), always set it to your naked root domain without https:// or www. (for example, example.com or yourdomain.com).
From that single value, Terraform automatically derives:
- Your S3 state bucket name (
example-com-tfstate) - Your regional S3 bucket names (
example-com-ap-northeast-1,example-com-eu-west-1, etc.) - Your ACM wildcard TLS certificate (
example.comand*.example.com) - Your canonical
https://www.example.comprimary URLURLUniform Resource LocatorNetworkingA specific type of Uniform Resource Identifier (URI) that both identifies a web resource and specifies the network protocol (e.g. https://) and hostname needed to retrieve it. and301edge redirect fromhttps://example.com
Step 2A: Provision via GitLab CI/CD (Option A)
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 in GitLab
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 | Naked root domain in Route 53 (no https:// or www.); auto-derives <domain-slug>-tfstate |
AWS_DEFAULT_REGION | Optional | ap-northeast-1 (default) | Default AWS CLICLICommand-Line InterfaceDevOpsA text-based user interface mechanism that processes terminal commands to interact directly with programs, operating systems, and cloud APIs. 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)
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 (allow ~20–30 minutes on the first greenfield run). - 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
Return to Settings → CI/CD → Variables in GitLab 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 |
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 2B: Provision via GitHub Actions (Option B)
If you host your repository on GitHub, the included .github/workflows/deploy.yml workflow handles automated Astro builds, S3 delta deployments, and Terraform validation.
1. Provision the Initial AWS Stack
Run the one-time Terraform bootstrap and initial apply from your terminal (or a GitHub Codespace) using your AWS credentials:
# 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 (~20-30 mins first run)
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
2. Configure GitHub Actions Secrets & Variables
Open your GitHub repository Settings → Secrets and variables → Actions and add:
Repository Secrets:
AWS_ACCESS_KEY_ID: Your AWS IAM Access Key IDAWS_SECRET_ACCESS_KEY: Your AWS IAM Secret Access Key
Repository Variables:
TF_VAR_environment_domain:example.comAWS_DEFAULT_REGION:ap-northeast-1(or your primary upload bucket region)S3_BUCKET: Value ofterraform output -raw primary_upload_bucket(e.g.example-com-ap-northeast-1)CLOUDFRONT_DISTRIBUTION_ID: Value ofterraform output -raw cloudfront_distribution_id(e.g.E1A2B3C4D5E6F7)
Once S3_BUCKET is set in GitHub Actions Variables, .github/workflows/deploy.yml automatically switches out of Starter Template Dry-Run Mode and builds + deploys your site on every push to main!
Step 2C: Provision via Local CLI (Option C)
If you prefer provisioning and deploying directly from your workstation terminal without CI/CD:
# 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 (~20-30 mins first run)
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, build and 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
Key implementation details across terraform/ and scripts/s3-deploy.sh:
1. Native SigV4a Origin Access Control & Managed Security Headers (terraform/cloudfront.tf)
aws_cloudfront_origin_access_control signs origin requests with sigv4a directly against the MRAP endpoint. 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"]
}
}
In addition, default_cache_behavior attaches AWS’s Managed-SecurityHeadersPolicy (67f7725c-6f97-4210-82d7-5512b31e9d03) so every response includes Strict-Transport-Security, X-Content-Type-Options: nosniff, X-Frame-Options: SAMEORIGIN, and Referrer-Policy headers automatically.
2. Apex Redirect & Directory Index Rewriting (terraform/cloudfront.tf)
Because an S3 Multi-Region Access Point uses the S3 RESTRESTRepresentational State TransferArchitectureAn architectural style for networked hypermedia APIs that uses stateless HTTP verbs (GET, POST, PUT, DELETE) to manipulate structured resource representations. 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. The 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;
}
Next Up: Automated CI/CD Dry-Run Workflows, Static Hosts & Two-Tier Edge Caching
Now that you know how the optional multi-region AWS stack is provisioned, continue to Part 3 — Automated CI/CD Pipelines, Starter Dry-Run Mode & Deploying to Any Static Host for a deep-dive into:
- Deploying
./dist/to zero-config platforms (Vercel, Netlify, Cloudflare Pages) - How the dual GitLab CI (
.gitlab-ci.yml) and GitHub Actions (.github/workflows/deploy.yml) pipelines run in Starter Template Dry-Run Mode out of the box - How
scripts/s3-deploy.shuses 4-tier split Edge/BrowserCache-Controlheaders and MD5-vs-ETag delta sync to keep CloudFront cache hit ratios near 100% while updating live pages in seconds.

About the author
Alex Mercer
Principal Cloud & Platform Architect specializing in multi-region edge infrastructure, declarative Terraform automation, high-throughput distributed systems, and architectural photography.
Contact
Thoughts or questions on this article?
Found a bug, tested a similar setup, or want to discuss the details? Drop a message below.