Serve Static Site from Private S3 Bucket

Table of Contents

2025-12-11, Thu

1. Introduction

There are a few options to serve static site from private S3 bucket, one of which is to use CloudFront.

In order to do it manually, we need to

  • Create a S3 bucket that blocks all public access, upload site files to it
  • Create a CloudFront distribution, along with a new OAC Setting (Origin Access Control). Copy the generated S3 bucket policy
  • Go to S3 bucket Permissions tab and apply the copied CloudFront OAC policy

And it's done — Unless you want to access the site with a custom subdomain (with the main domain registered with Route 53 already), then we also need to:

  • Request a ACM Certificate for our subdomain name in the us-east-1 region
  • Add the ACM Certificate validation record into Route 53 and wait for Certificate status becomes "Issued"
  • In CloudFront distribution setting, add alternate domain name that points to custom domain, along with SSL certificate that just got issued by ACM
  • In Route 53 hosted zone, add another alias A record that routes traffic to the CloudFront distribution

After everything is done properly, we should be able to visit custom-domain-name to access our file.

We could use tool like terraform to automate steps above. These steps could be splitted into one root module and three submodules, with a directory structure like this:

  • ./
  • ./modules/aws-s3-static-website
  • ./modules/aws-cloudfront-website
  • ./modules/aws-route53-website

They will be explained in detail one by one:

2. The Root Module

The input of root module requires the existing main domain (i.e. hosted_zone) and custom subdomain name, as shown below

# ref: ./variables.tf
variable "existing_zone_name" {
  description = "exsting zone name"
  type = string
  default = "tangwenfei.org"
}

variable "custom_domain_name" {
  description = "custom CNAME record name"
  type = string
  default = "mec"
}

As for the root entry, it handles ACM Certificate creation and invokes other submodules, e.g.

# ref: ./main.tf
# the current working region    
provider "aws" {
  region  = "us-west-1"
  profile = "mec-profile"
}

# the special region for ACM Certificate
provider "aws" {
  region = "us-east-1"
  profile = "mec-profile"
  alias = "us_east_1"
}

locals {
  full_domain_name = "${var.custom_domain_name}.${var.existing_zone_name}"
}

# Step 1/3: Request the ACM certificate from us-east-1 region
resource "aws_acm_certificate" "custom_domain_cert" {
  provider = aws.us_east_1
  domain_name = local.full_domain_name
  validation_method = "DNS"

  lifecycle {
    create_before_destroy = true
  }
}

# Step 2/3: Add ACM Cert Validation record in Route 53, done in ./modules/aws-route53-website

# Step 3/3: Wait for the certificat to be validated,
# i.e. Certificate status changed from "Pending Validation" to "Issued"
resource "aws_acm_certificate_validation" "cert_validation" {
  provider = aws.us_east_1
  certificate_arn = aws_acm_certificate.custom_domain_cert.arn

  # theres's typically only one record for each manually created ACM Certificate
  validation_record_fqdns = [for fqdn in module.website_route53.cert_record_fqdns : fqdn]
}

module "website_s3_bucket" {
  source = "./modules/aws-s3-static-website"

  bucket_name = "mec-bucket-2025"

  cloudfront_arn = module.website_cloudfront.cloudfront_arn

  tags = {
    Terraform   = "true"
    Environment = "dev"
  }
}

module "website_cloudfront" {
  source = "./modules/aws-cloudfront-website"

  oac_name        = "cloudfront-oac-for-s3-mec-bucket-2025"
  oac_description = "OAC for CloudFront to access S3"
  root_object     = "index.html"

  domain_name = module.website_s3_bucket.regional_domain_name
  bucket_id = module.website_s3_bucket.id
  custom_full_domain = local.full_domain_name

  # This ARN is supposed to be the same with aws_acm_certificate.custom_domain_cert.arn
  acm_cert_arn = aws_acm_certificate_validation.cert_validation.certificate_arn
}

module "website_route53" {
  source = "./modules/aws-route53-website"

  zone_name = var.existing_zone_name
  custom_domain_name = var.custom_domain_name
  cloudfront_domain_name = module.website_cloudfront.domain_name
  cloudfront_hosted_zone_id = module.website_cloudfront.hosted_zone_id
  acm_cert_domain_validation_options = aws_acm_certificate.custom_domain_cert.domain_validation_options
}

As for the output of the root module, it contains the s3 bucket name, the CloudFront domain, and our custom subdomain name.

# ref: ./outputs.tf
output "cloudfront_domain_name" {
  description = "Domain name of CloudFront"
  value = module.website_cloudfront.domain_name
}

output "website_domain_name" {
  description = "custom domain name of the static site"
  value = "https://${var.custom_domain_name}.${var.existing_zone_name}"
}

Now move on to the submodules, starting with S3.

3. The S3 Module

The S3 module requires bucket name and CloudFront ARN as input, e.g.

# ref: ./modules/aws-s3-static-website/variables.tf
variable "bucket_name" {
  description = "Name of the s3 bucket. Must be unique"
  type        = string
}

# Note: it's the CloudFront ARN that is needed here, not CloudFront OAC ARN
variable "cloudfront_arn" {
  description = "the CloudFront ARN"
  type = string

}

variable "tags" {
  description = "Tags to set on the bucket."
  type        = map(string)
  default     = {}
}

The main entry of the S3 module creates a S3 bucket along with proper bucket policy for CloudFront access.

 # ref: ./modules/aws-s3-static-website/main.tf
 resource "aws_s3_bucket" "s3_bucket" {
   bucket = var.bucket_name
   force_destroy = true # This line allows destruction of a non-empty bucket

   tags = var.tags
 }

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

   index_document {
     suffix = "index.html"
   }

   error_document {
     key = "error.html"
   }
 }

 resource "aws_s3_bucket_policy" "s3_bucket" {
   bucket = aws_s3_bucket.s3_bucket.id

   policy = jsonencode({
     Version = "2012-10-17"
     Statement = [
       {
         Effect = "Allow",
         Principal = {
           Service = "cloudfront.amazonaws.com"
         },
         Action   = "s3:GetObject",
         Resource = "${aws_s3_bucket.s3_bucket.arn}/*",
         Condition = {
           StringEquals = {
             "AwS:SourceArn" = var.cloudfront_arn
           }
         }
       },
     ]
   }) 
}

The output of this module includes id, name, and regional_domain_name of the newly created S3 bucket.

# ref: ./modules/aws-s3-static-website/outputs.tf
output "name" {
  description = "Name (id) of the bucket"
  value       = aws_s3_bucket.s3_bucket.id
}

output "regional_domain_name" {
  description = "S3 bucket region"
  value = aws_s3_bucket.s3_bucket.bucket_regional_domain_name
}

output "id" {
  description = "S3 bucket id"
  value = aws_s3_bucket.s3_bucket.id
}

4. The CloudFront Module

The CloundFront module has following four required input parameters:

  • the S3 bucket regional domain name for content servicing
  • our custom domain name that serves as alternate domain name
  • the ACM Certificaet ARN for the alternate domain name

along with some other optional parameters:

# ref: ./modules/aws-cloudfront-website/variables.tf
variable "oac_name" {
  description = "Name of the CloudFront OAC(Origin Access Control) configuration"
  type        = string
}

variable "oac_description" {
  description = "Description of the CloudFront OAC configuration"
  type        = string
}

variable "root_object" {
  description = "root object for CloudFront distribution"
  type        = string
  default = "index.html"
}

variable "domain_name" {
  description = "S3 bucket regional domain name"
  type = string
}

variable "bucket_id" {
  description = "S3 Bucket Id"
  type = string
}

variable "custom_full_domain" {
  description = "custom FULL domain name in Route 53, i.e. your own domain to access the site"
  type = string
}

variable "acm_cert_arn" {
  description = "ACM Certificate ARN"
  type = string
}

The main entry of this module creates an OAC rule and CloudFront distribution that accesses S3 bucket, with alias that points to custom domain and proper viewer certificates.

# ref: ./modules/aws-cloudfront-website/main.tf
# Origin Access Control (OAC)
resource "aws_cloudfront_origin_access_control" "s3_oac" {
  name                              = var.oac_name
  description                       = var.oac_description
  origin_access_control_origin_type = "s3"
  signing_behavior                  = "always"
  signing_protocol                  = "sigv4"
}


resource "aws_cloudfront_distribution" "s3_distribution" {
  origin {
    domain_name              = var.domain_name
    origin_id                = "s3-origin-${var.bucket_id}"
    origin_access_control_id = aws_cloudfront_origin_access_control.s3_oac.id
  }

  enabled             = true
  is_ipv6_enabled     = true
  comment             = "CloudFront distribution for s3 static website"
  default_root_object = var.root_object


  default_cache_behavior {
    allowed_methods        = ["GET", "HEAD"]
    cached_methods         = ["GET", "HEAD"]
    target_origin_id       = "s3-origin-${var.bucket_id}"
    viewer_protocol_policy = "redirect-to-https"
    min_ttl                = 0
    default_ttl            = 3600
    max_ttl                = 86400
    compress               = true
    forwarded_values {
      query_string = false
      cookies {
        forward = "none"
      }
    }
  }

  restrictions {
    geo_restriction {
      restriction_type = "none"
    }
  }

  aliases = [
    var.custom_full_domain
  ]

  viewer_certificate {
    acm_certificate_arn = var.acm_cert_arn
    ssl_support_method = "sni-only"
  }

  tags = {
    Environment = "mec-custom-env"
    ManagedBy   = "Terraform"
  }
}

The CloudFront module outputs the following attributes of the distrbution resource:

  • arn
  • domain_name
  • hosted_zone_id
# ref: ./modules/aws-cloudfront-website/outputs.tf
output "cloudfront_arn" {
  description = "the CloudFront ARN for S3 bucket"
  value = aws_cloudfront_distribution.s3_distribution.arn
}

output "domain_name" {
  description = "The domain name of published CloudFront site"
  value = aws_cloudfront_distribution.s3_distribution.domain_name
}

output "hosted_zone_id" {
  description = "Hosted Zone Id of CloudFront site"
  value = aws_cloudfront_distribution.s3_distribution.hosted_zone_id
}

5. The Route 53 Module

The Route 53 module requires both the custom domain and CloudFront distribution domain, along with the ACM Certificate validation records.

# ref: .//modules/aws-route53-website/variables.tf
variable "zone_name" {
  description = "exsting zone name"
  type = string
}

variable "custom_domain_name" {
  description = "custom CNAME record name"
  type = string
}

variable "cloudfront_domain_name" {
  description = "CloudFront domain name"
  type = string
}

variable "cloudfront_hosted_zone_id" {
  description = "CloudFront hosted zone"
  type = string
}

variable "acm_cert_domain_validation_options" {
  description = "List of domain validation options from the ACM Certificate"
  # type = any
  type = list(object({
    domain_name = string
    resource_record_name = string
    resource_record_type = string
    resource_record_value = string
  }))
}

The main entry of the Route 53 module adds two types of records:

  • an A type alias record for our custom domain that routes traffic to CloudFront distribution, and
  • a validation record for validating the ACM issued certificate
# ref: .//modules/aws-route53-website/main.tf
locals {
  full_domain_name = "${var.custom_domain_name}.${var.zone_name}"
}

# retrieve info of existing zone, e.g. tangwenfei.org
data "aws_route53_zone" "existing_hosted_zone" {
  name = var.zone_name
}

resource "aws_route53_record" "custom_site_domain" {
  zone_id = data.aws_route53_zone.existing_hosted_zone.zone_id
  name = local.full_domain_name
  # name = var.custom_domain_name
  type = "A"

  alias {
    name = var.cloudfront_domain_name
    zone_id = var.cloudfront_hosted_zone_id
    evaluate_target_health = false
  }
}

# ============================================================
# Add DNS record for the ACM Ceritificate

# Step 1/3: Request ACM Certififcate from us-east-1 region, which is done in the root module
# Step 2/3: Add ACM Certificate Validation record into Route 53
# Step 3/3: Wait for the ACM Certificate to become Issued. Done in the root module

resource "aws_route53_record" "cert_validation_record" {
  for_each = {
    for dvo in var.acm_cert_domain_validation_options : dvo.domain_name => dvo
  }

  name = each.value.resource_record_name
  records = [each.value.resource_record_value]
  type = each.value.resource_record_type
  zone_id = data.aws_route53_zone.existing_hosted_zone.zone_id
  ttl = 60
}

This module outputs the fully qualified domain name for validation of ACM issued certificate, i.e. checking that its status has been chagned from "Pending Validation" to "Issued".

# ref: .//modules/aws-route53-website/output.tf
output "cert_record_fqdns" {
  description = "The Route53 validation records added for ACM Certificate"
  value = values(aws_route53_record.cert_validation_record)[*].fqdn
}

6. Conclusion

And that's it. To run the plan and check the result, run the following sample script:

#!/bin/sh
# current work directory .
terraform init
terraform plan --out out/plan.out
terraform apply --auto-aprove out/plan.out
aws --profile mec-profile s3 cp /path/to/index.html s3://mec-bucket-2025
curl https://mec.tangwenfei.org
# Release resource
# terraform destroy

"Static" content ends here. Below content talks about dynamic content.


7. Let Go Dynamic

For dynamic content served through RESTful API, it could be achieved through a lot of different ways. Lambda + API Gateweay is the combination that will be used here for demonstration.

Four parts will be talked about here:

  • The source code for backend and frontend
  • The new Lambda Func module
  • The new API Gateway module
  • The updated CloudFront module and Root module

8. The Source Code

For backend, it's the source code of Lambda function.

// ref: ./backend/hello.js
// ref: https://developer.hashicorp.com/terraform/tutorials/aws/lambda-api-gateway
// Lambda function code
module.exports.handler = async (event) => {
    console.log('Event: ', event);
    let responseMessage = 'Hello, World!';

    if (event.queryStringParameters && event.queryStringParameters['Name']) {
        responseMessage = `Hello, ${event.queryStringParameters['Name']}!`;
    }

    return {
        statusCode: 200,
        headers: {
            'Content-Type': 'application/json',
        },
        body: JSON.stringify({
            message: responseMessage,
        }),
    }
}

For frontend, it's the updated index.html that calls backend API.

<!-- ref: ./www/index.html -->
<!DOCTYPE html>
<html>
  <head>
    <title>Static Website</title>
    <script type="text/javascript">
      const _noop = () => {};
      async function getGreetings(name = '', onResponse = showGreetings) {
        let url = '/api/hello';
        try {
          if (name) {
          url = `${url}?Name=${name}`;
          }
          const resp = await fetch(url);
          const data = await resp.json(); // parse the response body to JSON
          onResponse(data);
        } catch(e) {
          console.error(e);
          onResponse(`Failed to get response: ${e}. See console for more info.`);
        }
      }

      document.addEventListener('DOMContentLoaded', () => {

      const responseDiv = document.getElementById('response');
      function showGreetings(data) {
        responseDiv.innerHTML = data.message;
      }

      const nameTxt = document.getElementById('name');
      const greetingBtn = document.getElementById('greetingBtn');
      nameTxt.addEventListener('keyup', (e) => {
        if (e.key === 'Enter') {
          greetingBtn.click();
        }
      });
      greetingBtn.addEventListener('click', () => {
        const name = nameTxt.value
        getGreetings(name, showGreetings);
        nameTxt.value = '';
        nameTxt.focus();
      });

      getGreetings(undefined, showGreetings);
        nameTxt.focus();
      });

    </script>
  </head>
  <body>
    <p>Nothing to see here</p>
    <div id="response"></div>
    <div>
      <input id="name" type="text"></input>
      <button id="greetingBtn">Send Greetings</button>
    </div>

  <body>
</html>

9. The Lambda Func Module

The Lambda func module defines the Lambda function with source code in S3 bucket.

# ref: ./modules/aws-lambda-func/main.tf
resource "aws_lambda_function" "hello_world" {
  function_name = "HelloWorld"

  s3_bucket = var.s3_bucket_id
  s3_key = var.s3_object_key

  runtime = "nodejs20.x"
  handler = "hello.handler"

  source_code_hash = var.source_code_hash
  role = aws_iam_role.lambda_exec.arn
}

resource "aws_cloudwatch_log_group" "hello_world" {
  name = "/aws/lambda/${aws_lambda_function.hello_world.function_name}"

  retention_in_days = 1
}

resource "aws_iam_role" "lambda_exec" {
  name = "serverless-lambda-mec"

  assume_role_policy = jsonencode({
    Version = "2012-10-17"
    Statement = [{
        Action = "sts:AssumeRole"
        Effect = "Allow"
        Sid = ""
        Principal = {
          Service = "lambda.amazonaws.com"
        }
    }]
  })

  tags = {
    ManagedBy = "Terraform"
  }
}

resource "aws_iam_role_policy_attachment" "lambda_policy" {
  role = aws_iam_role.lambda_exec.name
  policy_arn = "arn:aws:iam::aws:policy/service-role/AWSLambdaBasicExecutionRole"
}

The input and output are as expected:

# ref: ./modules/aws-lambda-func/variables.tf
variable "s3_bucket_id" {
  description = "s3 bucket id"
  type = string
}

variable "s3_object_key" {
  description = "s3 object key"
  type = string
}

variable "source_code_hash" {
  description = "source code hash"
  type = string
}
# ref: ./modules/aws-lambda-func/outputs.tf
# ref: https://developer.hashicorp.com/terraform/tutorials/aws/lambda-api-gateway
output "function_name" {
  description = "name of the Lambda function"
  value = aws_lambda_function.hello_world.function_name
}

output "invoke_arn" {
  description = "the invocation arn"
  value = aws_lambda_function.hello_world.invoke_arn
}

output "lambda_log_group_arn" {
  description = "lambda function log group arn"
  value = aws_cloudwatch_log_group.hello_world.arn
}

10. The API Gateway Module

The API Gateway module defines an API gateway and wires it up with exisitng Lambda function.

# ref: ./modules/aws-api-gateway/main.tf
# ref: https://developer.hashicorp.com/terraform/tutorials/aws/lambda-api-gateway

# defines the API Gateway
resource "aws_apigatewayv2_api" "lambda" {
  # name  = "serverless_lambda_gw_mec"
  name = var.api_gateway_name
  protocol_type = "HTTP"
}

resource "aws_apigatewayv2_stage" "lambda" {
  api_id = aws_apigatewayv2_api.lambda.id

  # name = "serverless_lambda_stage_mec"
  name = var.api_gateway_stage_name
  auto_deploy = true

  access_log_settings {
    destination_arn = var.lambda_log_group_arn

    format = jsonencode({
      requestedId = "$context.requestId"
      sourceIp = "$context.identity.sourceIp"
      requestTime = "$context.requestTime"
      protocol = "$context.protocol"
      httpMethod = "$context.httpMethod"
      resourcePath = "$context.resourcePath"
      routeKey = "$context.routeKey"
      status = "$context.status"
      responseLength = "$context.responseLength"
      integrationErrorMessage = "$context.integrationErrorMessage"
    })
  }
}

resource "aws_apigatewayv2_integration" "hello_world" {
  api_id = aws_apigatewayv2_api.lambda.id

  integration_uri = var.func_invoke_arn
  integration_type  = "AWS_PROXY"
  integration_method = "POST"
}

resource "aws_apigatewayv2_route" "hello_world" {
  api_id = aws_apigatewayv2_api.lambda.id

  # Note that the path here should match the path_pattern in CloudFront config
  route_key = "GET /${var.api_path_prefix}/hello"
  target = "integrations/${aws_apigatewayv2_integration.hello_world.id}"
}

resource "aws_cloudwatch_log_group" "api_gw" {
  name = "/aws/api_gw/${aws_apigatewayv2_api.lambda.name}"
  retention_in_days = 1
}

resource "aws_lambda_permission" "api_gw" {
  statement_id = "AllowExecutionFromAPIGateway"
  action = "lambda:InvokeFunction"
  function_name = var.func_name
  principal = "apigateway.amazonaws.com"

  source_arn = "${aws_apigatewayv2_api.lambda.execution_arn}/*/*"
}

Input and output mainly concern with Lambda function and the API endpoint.

# ref: ./modules/aws-api-gateway/variables.tf
# ref: https://developer.hashicorp.com/terraform/tutorials/aws/lambda-api-gateway
variable "api_gateway_name" {
  description = "api gateway name"
  type = string
}

variable "api_gateway_stage_name" {
  description = "api gateway stage name"
  type = string
}

variable "lambda_log_group_arn" {
  description = "Lambda function log group ARN"
  type = string
}

variable "func_invoke_arn" {
  description = "the Lambda function invoke arn"
  type = string
}
variable "func_name" {
  description = "Lambda function name"
  type = string
}

variable "api_path_prefix" {
  description = "API Path prefix (without leading /), e.g. api, api-test, etc."
  type = string
}
# ref: ./modules/aws-api-gateway/outputs.tf
# ref: https://developer.hashicorp.com/terraform/tutorials/aws/lambda-api-gateway
output "invoke_url" {
  description = "base URL for API gateway stage"
  value = aws_apigatewayv2_stage.lambda.invoke_url
}

output "api_id" {
  description = "api id"
  value = aws_apigatewayv2_api.lambda.id
}

output "stage_name" {
  description = "api stage name"
  value = aws_apigatewayv2_stage.lambda.name
}


output "api_endpoint" {
  description = "the endpoint of the API"
  value = aws_apigatewayv2_api.lambda.api_endpoint
}

output "api_stage_name"{
  description = "API stage name"
  value = aws_apigatewayv2_stage.lambda.name
}

11. The CloudFront Module: Updated

The CloudFront module adds routing config for API endpoints in the aws_cloudfront_distribution block.

# ref: ./modules/aws-cloudfront-website/main.tf
resource "aws_cloudfront_distribution" "s3_distribution" {
  ... (existing S3 configs are left intact)
  origin {
    # Extract domain from endpoint by stripping 'https://' prefix
    domain_name = replace(var.api_endpoint, "/^https?://([^/]*).*/", "$1")
    origin_id = "api-gateway-origin-mec-hello-world-2025"
    origin_path = "/${var.api_stage_name}"
    custom_origin_config {
      http_port = 80
      https_port = 443
      origin_protocol_policy = "https-only"
      origin_ssl_protocols = ["TLSv1.2"]
    }
  }

  ordered_cache_behavior {
    # Requests to the API gateway domain goes here
    # path_pattern should match the path in aws_apigatewayv2_route.route_key
    # path_pattern = "/api/*"
    path_pattern = "/${var.api_path_prefix}/*"
    target_origin_id = "api-gateway-origin-mec-hello-world-2025"
    allowed_methods = ["GET", "HEAD", "OPTIONS", "PUT", "POST", "DELETE", "PATCH"]
    cached_methods = ["GET", "HEAD", "OPTIONS"]
    viewer_protocol_policy = "redirect-to-https"

    # ref: https://docs.aws.amazon.com/AmazonCloudFront/latest/DeveloperGuide/using-managed-origin-request-policies.html#managed-origin-request-policy-all-viewer-except-host-header
    # ref: https://docs.aws.amazon.com/AmazonCloudFront/latest/DeveloperGuide/using-managed-cache-policies.html
    # Use AWS managed policy for API Gateway caching (for API call)
    origin_request_policy_id = "b689b0a8-53d0-40ab-baf2-68738e2966ac" # AllViewerExceptHostHeader
    cache_policy_id = "4135ea2d-6df8-44a3-9df3-4b5a84be39ad" # CachingDisabled    
    compress = true
    # Typically APIs are not cached, so set TTSs to 0
    min_ttl = 0
    default_ttl = 0
    max_ttl = 0
  }
}

New input variables are all also about the API endpoint.

# ref: ./modules/aws-cloudfront-website/variables.tf
...existing S3 configs are left intact
variable "api_endpoint" {
  # description = "custom FULL domain name in Route 53 for the API call"
  description = "API gateway endpoint"
  type = string
}

variable "api_stage_name" {
  description = "API Stage Name"
  type = string
}

variable "api_path_prefix" {
  description = "API Path prefix (without leading /), e.g. api, api-test, etc."
  type = string
}

12. The Root Module: Updated

The root module have two changes:

  • Invoke the new modules defined for Lambda and API Gateway, and
  • Update existing CloudFront block to wire things up
# ref: ./main.tf

# ============================================================
# Update existing blocks
# ============================================================
locals {
  ...keep existing content intact
  full_api_domain_name = "api.${var.custom_domain_name}.${var.existing_zone_name}"
  zip_file_name = "hello-world.zip"
  api_path_prefix = "api"
}

module "website_cloudfront" {
  ...keep existing content intact
  api_endpoint = module.api_gateway.api_endpoint
  api_stage_name = module.api_gateway.api_stage_name

  api_path_prefix = local.api_path_prefix
}

# ============================================================
# New blocks
# ============================================================
data "archive_file" "lambda_hello_world" {
  type = "zip"

  source_dir = "${path.module}/backend"
  output_path = "${path.module}/${local.zip_file_name}"
}

resource "aws_s3_object" "lambda_hello_world" {
  # bucket = aws_s3_bucket.s3_bucket.id
  bucket = module.website_s3_bucket.id

  key = local.zip_file_name
  source = data.archive_file.lambda_hello_world.output_path
  etag = filemd5(data.archive_file.lambda_hello_world.output_path)

}

module "lambda_func" {
  source = "./modules/aws-lambda-func"

  s3_bucket_id = module.website_s3_bucket.id
  # s3_object_key = module.website_s3_bucket.object_key
  s3_object_key = local.zip_file_name
  source_code_hash = data.archive_file.lambda_hello_world.output_base64sha256
}

module "api_gateway" {
  source = "./modules/aws-api-gateway"

  api_gateway_name = "mec-hello-world-lambda-gw"
  api_gateway_stage_name = "mec-stage"
  lambda_log_group_arn = module.lambda_func.lambda_log_group_arn

  func_invoke_arn = module.lambda_func.invoke_arn
  func_name = module.lambda_func.function_name
  api_path_prefix = local.api_path_prefix
}


13. Conclusion: Pt. 2

Update and apply the new Terraform plan:

#!/bin/sh
# current work directory .
terraform init
terraform plan --out out/plan.out
terraform apply --auto-aprove out/plan.out
curl https://mec.tangwenfei.org/api/hello
# Test the site in browser
# open -a "Google Chrome" https://mec.tangwenfei.org
# Release resource 
# terraform destroy

Now dynamic content could be requested from https://mec.tangwenfei.org/api/hello.

14. References