Web Module

The web module that you are going to implement has the following folder structure:

web/
│
├── cloudfront/
│
├── s3-web/
│   
├── locals.tf
│   
├── main.tf
│   
├── outputs.tf
│   
└── variables.tf

You then start building the submodules and configuration files required for the web module.

1. The web/cloudfront submodule is defined by the following structure:

web/
│
├── cloudfront/
│   │
│   ├── locals.tf
│   │
│   ├── main.tf
│   │ 
│   ├── outputs.tf
│   │
│   └── variables.tf
└── ...

Fill the following lines of code to web/cloudfront/locals.tf:

locals {
  s3_origin_id               = "S3WebOrigin"
  alb_origin_id              = "AlbAppOrigin"
  caching_disabled_policy_id = "4135ea2d-6df8-44a3-9df3-4b5a84be39ad"
}

Fill the following lines of code to web/cloudfront/variables.tf:

variable "bucket_regional_domain_name" {
  description = "The regional domain name of the bucket"
  type        = string
}

variable "alb_domain_name" {
  description = "The domain name of the alb"
  type        = string
}

variable "domain_name" {
  description = "The domain name for the CloudFront distribution"
  type        = string
}

variable "hosted_zone_id" {
  description = "The id of the Route53 hosted zone"
  type        = string
}

variable "certificate_arn" {
  description = "The arn of the certificate"
  type        = string
}

Fill the following lines of code to web/cloudfront/main.tf:

resource "aws_cloudfront_distribution" "main" {
  origin {
    origin_id                = local.s3_origin_id
    domain_name              = var.bucket_regional_domain_name
    origin_access_control_id = aws_cloudfront_origin_access_control.main.id
  }

  origin {
    origin_id   = local.alb_origin_id
    domain_name = var.alb_domain_name
    custom_origin_config {
      http_port              = 80
      https_port             = 443
      origin_protocol_policy = "match-viewer"
      origin_ssl_protocols   = ["TLSv1.2", "TLSv1.1"]
    }
  }

  enabled             = true
  default_root_object = "index.html"
  aliases             = [var.domain_name]

  default_cache_behavior {
    target_origin_id = local.s3_origin_id
    cache_policy_id  = aws_cloudfront_cache_policy.s3_origin.id

    allowed_methods        = ["GET", "HEAD"]
    cached_methods         = ["GET", "HEAD"]
    viewer_protocol_policy = "redirect-to-https"
    compress               = true
  }

  ordered_cache_behavior {
    path_pattern     = "/api*"
    target_origin_id = local.alb_origin_id
    cache_policy_id  = local.caching_disabled_policy_id

    allowed_methods        = ["DELETE", "GET", "HEAD", "OPTIONS", "PATCH", "POST", "PUT"]
    cached_methods         = ["GET", "HEAD"]
    compress               = true
    viewer_protocol_policy = "redirect-to-https"
  }

  restrictions {
    geo_restriction {
      restriction_type = "none"
      locations        = []
    }
  }

  viewer_certificate {
    acm_certificate_arn      = var.certificate_arn
    minimum_protocol_version = "TLSv1.2_2021"
    ssl_support_method       = "sni-only"
  }
}

resource "aws_cloudfront_cache_policy" "s3_origin" {
  name    = "s3-origin-cloudfront-cache-policy"
  comment = "CloudFront cache policy for s3 origin"

  default_ttl = 1800
  max_ttl     = 86400
  min_ttl     = 0

  parameters_in_cache_key_and_forwarded_to_origin {
    cookies_config {
      cookie_behavior = "none"
      cookies {
        items = []
      }
    }
    headers_config {
      header_behavior = "none"
      headers {
        items = []
      }
    }
    query_strings_config {
      query_string_behavior = "none"
      query_strings {
        items = []
      }
    }

    enable_accept_encoding_brotli = true
    enable_accept_encoding_gzip   = true
  }
}

resource "aws_route53_record" "main" {
  zone_id = var.hosted_zone_id
  name    = var.domain_name
  type    = "A"

  alias {
    name                   = aws_cloudfront_distribution.main.domain_name
    zone_id                = aws_cloudfront_distribution.main.hosted_zone_id
    evaluate_target_health = false
  }
}

resource "aws_cloudfront_origin_access_control" "main" {
  name                              = var.bucket_regional_domain_name
  origin_access_control_origin_type = "s3"
  signing_behavior                  = "always"
  signing_protocol                  = "sigv4"
}

Fill the following lines of code to web/cloudfront/outputs.tf:

output "arn" {
  description = "The arn of cloudfront"
  value       = aws_cloudfront_distribution.main.arn
}

2. The web/s3-web submodule is defined by the following structure:

web/
│
├── s3-web/
│   │
│   ├── allow-cloudfront-policy.tpl
│   │
│   ├── dependencies.tf
│   │
│   ├── main.tf
│   │ 
│   ├── outputs.tf
│   │
│   └── variables.tf
└── ...

Fill the following lines of code to web/s3-web/allow-cloudfront-policy.tpl:

{
  "Version": "2012-10-17",
  "Statement": [
    {
      "Sid": "AllowCloudFrontServicePrincipal",
      "Effect": "Allow",
      "Action": "s3:GetObject",
      "Resource": "${s3_bucket_arn}/*",
      "Principal": {
        "Service": "cloudfront.amazonaws.com"
      },
      "Condition": {
        "StringEquals": {
          "AWS:SourceArn": "${cloudfront_arn}"
        }
      }
    }
  ]
}

Fill the following lines of code to web/s3-web/dependencies.tf:

data "aws_caller_identity" "current" {
  count = var.enable_bucket_versioning ? 1 : 0
}

Fill the following lines of code to web/s3-web/variables.tf:

variable "bucket_name" {
  description = "The name of the bucket"
  type        = string
}

variable "cloudfront_arn" {
  description = "The arn of cloudfront"
  type        = string
}

variable "enable_bucket_versioning" {
  description = "Enable bucket versioning to keep versions of the web"
  type        = bool
  default     = false
}

variable "mfa_code" {
  description = "The mfa code required if enabling bucket versioning"
  type        = string
  sensitive   = true
  default     = ""
}

variable "force_destroy" {
  description = "If true, remove all items in the bucket and then remove the bucket"
  type = bool
  default = false
}

Fill the following lines of code to web/s3-web/main.tf:

resource "aws_s3_bucket" "main" {
  bucket = var.bucket_name
  force_destroy = var.force_destroy
}

resource "aws_s3_bucket_policy" "allow_cloudfront" {
  bucket = aws_s3_bucket.main.id
  policy = templatefile("${path.module}/allow-cloudfront-policy.tpl", {
    s3_bucket_arn  = aws_s3_bucket.main.arn,
    cloudfront_arn = var.cloudfront_arn
  })
}

resource "aws_s3_bucket_website_configuration" "main" {
  bucket = aws_s3_bucket.main.id

  index_document {
    suffix = "index.html"
  }

  error_document {
    key = "error.html"
  }
}

resource "aws_s3_bucket_versioning" "main" {
  count  = var.enable_bucket_versioning ? 1 : 0
  bucket = aws_s3_bucket.main.id

  expected_bucket_owner = data.aws_caller_identity.current[0].account_id
  mfa                   = var.mfa_code

  versioning_configuration {
    status     = "Enabled"
    mfa_delete = "Enabled"
  }
}

Fill the following lines of code to web/s3-web/outputs.tf:

output "bucket_regional_domain_name" {
  description = "The regional domain name of the bucket"
  value       = aws_s3_bucket.main.bucket_regional_domain_name
}

output "bucket_arn" {
  description = "The arn of bucket"
  value       = aws_s3_bucket.main.arn
}

3. You now implement the Terraform configuration files for web module.

Fill the following lines of code to web/locals.tf:

locals {
  resource_name = "${var.environment}-${var.project_name}"
}

Fill the following lines of code to web/variables.tf:

variable "environment" {
  description = "The environment to which the web delploys"
  type        = string
}

variable "project_name" {
  description = "The name of the project"
  type        = string
}

variable "enable_bucket_versioning" {
  description = "Enable bucket versioning to keep versions of the web"
  type        = bool
  default     = false
}

variable "hosted_zone_id" {
  description = "The id of the Route53 hosted zone"
  type        = string
}

variable "domain_name" {
  description = "The domain name of the website"
  type        = string
}

variable "alb_domain_name" {
  description = "The domain name of the alb"
  type        = string
}

variable "github_org" {
  description = "The GitHub organization that the Github Actions role trusts"
  type        = string
}

variable "github_repo" {
  description = "The GitHub repository that the Github Actions role trusts"
  type        = string
}

variable "mfa_code" {
  description = "The mfa code required if enabling bucket versioning"
  type        = string
  sensitive   = true
  default     = ""
}


variable "gh_oidc_provider_arn" {
  description = "The arn of GitHub openid connect provider"
  type        = string
}

variable "s3_bucket_force_destroy" {
  description = "If true, remove all items in the bucket and then remove the bucket"
  type = bool
  default = false
}

Fill the following lines of code to web/main.tf:

#----------------------------------------------------------------------
# TSL Certificate 
#----------------------------------------------------------------------
module "certificate" {
  source = "../security/certificate"

  domain_name    = var.domain_name
  hosted_zone_id = var.hosted_zone_id
}

#----------------------------------------------------------------------
# CloudFront
#----------------------------------------------------------------------
module "cloudfront" {
  source = "./cloudfront"

  bucket_regional_domain_name = module.s3_web.bucket_regional_domain_name
  domain_name                 = var.domain_name
  alb_domain_name = var.alb_domain_name
  hosted_zone_id              = var.hosted_zone_id
  certificate_arn             = module.certificate.arn
}

#----------------------------------------------------------------------
# S3 bucket for hosting ReactJS website
#----------------------------------------------------------------------
module "s3_web" {
  source = "./s3-web"

  bucket_name              = local.resource_name
  cloudfront_arn           = module.cloudfront.arn
  enable_bucket_versioning = var.enable_bucket_versioning
  mfa_code                 = var.mfa_code
  force_destroy = var.s3_bucket_force_destroy
}

#----------------------------------------------------------------------
# GitHub Actions role for deploying web
#----------------------------------------------------------------------
module "gha_role" {
  source = "../security/gha-role"

  gh_oidc_provider_arn = var.gh_oidc_provider_arn

  role_name   = "gha-role-for-deploying-web"
  github_repo = var.github_repo
  github_org  = var.github_org

  policy_arn = aws_iam_policy.allow_deploy_web.arn
}

resource "aws_iam_policy" "allow_deploy_web" {
  name        = "AllowDeployWeb"
  description = "IAM policy for deploying static website to S3 bucket"

  policy = templatefile("${path.module}/allow-deploy-web-policy.tpl", {
    s3_bucket_arn = module.s3_web.bucket_arn
  })
}

Fill the following lines of code to web/outputs.tf:

output "gha_role_deploy_web_arn" {
  description = "The role arn for GitHub Actions that deploys web"
  value       = module.gha_role.arn
}

4. Commit and push the module to the GitHub repository.

git add . && \
git commit -m "add web module" && \
git push