I've spent a whole day to understand how to setup API gateway with my private NLB which is a LoadBalancer type of EKS service and expose some of the private REST API to public internet with api key protection. here is the terraform scripts to manage this journey

tf_and_api_gw

the api

it's where everything starts, the REST API

resource "aws_api_gateway_rest_api" "acme-api-gw-rest" {
  name = "${var.env-name}-acme-api-gw-rest-${var.region-name}"
  endpoint_configuration {
    types = ["REGIONAL"]
  }
  description = "use to support transaction-id in global account"

  tags = var.resource_tags
}

rest api vpc link only support NLB, I've created the NLB with eks service loadbalancer with ALB controller, so put it's ARN here

resource "aws_api_gateway_vpc_link" "acme-api-gw-vpc-link" {
  name        = "${var.env-name}-acme-api-gw-vpc-link-${var.region-name}"
  description = "vpc link for acme rest api"
  target_arns = [var.acme-private-nlb-arn]

  tags = var.resource_tags
}

the resources

we could create as many resources as we want , the root resources could be referenced from aws_api_gateway_rest_api.acme-api-gw-rest.root_resource_id,
here I create a resource on path /acme/common/echo/{msg}
{msg} is the path parameter, we could pass to backend

resource "aws_api_gateway_resource" "acme-api-gw-rs-acme" {
  rest_api_id = aws_api_gateway_rest_api.acme-api-gw-rest.id
  parent_id   = aws_api_gateway_rest_api.acme-api-gw-rest.root_resource_id
  path_part   = "acme"
}
resource "aws_api_gateway_resource" "acme-api-gw-rs-acme-common" {
  rest_api_id = aws_api_gateway_rest_api.acme-api-gw-rest.id
  parent_id   = aws_api_gateway_resource.acme-api-gw-rs-acme.id
  path_part   = "common"
}
resource "aws_api_gateway_resource" "acme-api-gw-rs-acme-common-echo" {
  rest_api_id = aws_api_gateway_rest_api.acme-api-gw-rest.id
  parent_id   = aws_api_gateway_resource.acme-api-gw-rs-acme-common.id
  path_part   = "echo"
}
resource "aws_api_gateway_resource" "acme-api-gw-rs-acme-common-echo-msg" {
  rest_api_id = aws_api_gateway_rest_api.acme-api-gw-rest.id
  parent_id   = aws_api_gateway_resource.acme-api-gw-rs-acme-common-echo.id
  path_part   = "{msg}"
}

the method

we could attach GET/POST/PUT/DELETE/PATCH/HEAD or ANY method to a resource, if there is a path parameter, we need to declare in request_parameters section, later we will use it in integration

resource "aws_api_gateway_method" "acme-api-gw-rs-acme-common-echo-msg-get" {
  rest_api_id      = aws_api_gateway_rest_api.acme-api-gw-rest.id
  resource_id      = aws_api_gateway_resource.acme-api-gw-rs-acme-common-echo-msg.id
  http_method      = "GET"
  authorization    = "NONE"
  api_key_required = true

  request_parameters = {
    "method.request.path.msg" = true
  }
}
resource "aws_api_gateway_method_response" "acme-api-gw-rs-acme-common-echo-msg-get-200" {
  rest_api_id = aws_api_gateway_rest_api.acme-api-gw-rest.id
  resource_id = aws_api_gateway_resource.acme-api-gw-rs-acme-common-echo-msg.id
  http_method = aws_api_gateway_method.acme-api-gw-rs-acme-common-echo-msg-get.http_method
  status_code = "200"
}

the integration

integration represent the backend url which doing the actual work, we use connection_id to connect the integration into the VPC we created before and we also use request_parameters to map the request parameter to the integration path parameter.

resource "aws_api_gateway_integration" "acme-api-gw-rs-acme-common-echo-msg-get-integration" {
  rest_api_id             = aws_api_gateway_rest_api.acme-api-gw-rest.id
  resource_id             = aws_api_gateway_resource.acme-api-gw-rs-acme-common-echo-msg.id
  http_method             = aws_api_gateway_method.acme-api-gw-rs-acme-common-echo-msg-get.http_method
  connection_type         = "VPC_LINK"
  connection_id           = aws_api_gateway_vpc_link.acme-api-gw-vpc-link.id
  integration_http_method = "GET"
  type                    = "HTTP"
  uri                     = "http://${var.acme-private-nlb-dns}/acme/common/echo/{msg}"
  request_parameters = {
    "integration.request.path.msg" = "method.request.path.msg"
  }
}
resource "aws_api_gateway_integration_response" "acme-api-gw-rs-acme-common-echo-msg-get-integration-response" {
  rest_api_id = aws_api_gateway_rest_api.acme-api-gw-rest.id
  resource_id = aws_api_gateway_resource.acme-api-gw-rs-acme-common-echo-msg.id
  http_method = aws_api_gateway_method.acme-api-gw-rs-acme-common-echo-msg-get.http_method
  status_code = aws_api_gateway_method_response.acme-api-gw-rs-acme-common-echo-msg-get-200.status_code
}

the deployment and stage

now we could deploy the API into a stage, so that it could have a url to call from public internet and we could use api key to protect the api as well, like below
now we could call the API like

curl --header 'X-API-Key: xxx' -H 'Content-Type: application/json' https://z5kp58ywm9.execute-api.ap-southeast-2.amazonaws.com/dev/acme/common/echo/abc
rest_api_id = aws_api_gateway_rest_api.acme-api-gw-rest.id

  triggers = {
    # NOTE: The configuration below will satisfy ordering considerations,
    #       but not pick up all future REST API changes. More advanced patterns
    #       are possible, such as using the filesha1() function against the
    #       Terraform configuration file(s) or removing the .id references to
    #       calculate a hash against whole resources. Be aware that using whole
    #       resources will show a difference after the initial implementation.
    #       It will stabilize to only change when resources change afterwards.
    redeployment = sha1(jsonencode([
      aws_api_gateway_resource.acme-api-gw-rs-acme.id,
      aws_api_gateway_resource.acme-api-gw-rs-acme-common.id,
      aws_api_gateway_resource.acme-api-gw-rs-acme-common-echo.id,
      aws_api_gateway_resource.acme-api-gw-rs-acme-common-echo-msg.id,
      aws_api_gateway_method.acme-api-gw-rs-acme-common-echo-msg-get.id,
      aws_api_gateway_integration.acme-api-gw-rs-acme-common-echo-msg-get-integration.id,
    ]))
  }

  lifecycle {
    create_before_destroy = true
  }
}

resource "aws_api_gateway_stage" "acme-api-gw-stage" {
  deployment_id = aws_api_gateway_deployment.acme-api-gw-deployment.id
  rest_api_id   = aws_api_gateway_rest_api.acme-api-gw-rest.id
  stage_name    = var.env-name
}

resource "aws_api_gateway_usage_plan" "acme-api-gw-usage-plan" {
  name         = "${var.env-name}-acme-${var.region-name}"
  description  = "acme api usage plan"
  product_code = "acme"

  api_stages {
    api_id = aws_api_gateway_rest_api.acme-api-gw-rest.id
    stage  = aws_api_gateway_stage.acme-api-gw-stage.stage_name
  }

  quota_settings {
    limit  = 10000
    offset = 1
    period = "WEEK"
  }

  throttle_settings {
    burst_limit = 100
    rate_limit  = 50
  }
}
resource "aws_api_gateway_api_key" "acme-api-gw-default" {
  name = "${var.env-name}-acme-${var.region-name}"
}

resource "aws_api_gateway_usage_plan_key" "acme-api-gw-default" {
  key_id        = aws_api_gateway_api_key.acme-api-gw-default.id
  key_type      = "API_KEY"
  usage_plan_id = aws_api_gateway_usage_plan.acme-api-gw-usage-plan.id
}

the custom domain

sometimes we want to expose the API with our own domain over https, so first we need to request certificate from AWS Certificate Manager

resource "aws_acm_certificate" "acme-cert" {
  domain_name               = var.domain
  validation_method         = "DNS"
  subject_alternative_names = ["${var.env-name}-acme-gw.${var.region-name}.${var.domain}"]

  tags = var.resource_tags

  lifecycle {
    create_before_destroy = true
  }
}

then we could create the custom domain name,

  • create the domain name
  • register custom domain's DNS in route53 to our own domain, so that we could call the domian name
  • connect the custom domain name with a stage we have deployed

now we could call the api from a custom domain name like below

curl --header 'X-API-Key: xxx' -H 'Content-Type: application/json' https://acme-api.acme.com/acme/common/echo/abc
resource "aws_api_gateway_domain_name" "acme-api-gw-domain" {
  domain_name              = "${var.env-name}-acme-document-processors-gw.${var.region-name}.${var.domain}"
  regional_certificate_arn = aws_acm_certificate.acme-cert.arn

  endpoint_configuration {
    types = ["REGIONAL"]
  }
}
resource "aws_route53_record" "acme-api-gw-domain" {
  name    = aws_api_gateway_domain_name.acme-api-gw-domain.domain_name
  type    = "A"
  zone_id = var.zone_id

  alias {
    evaluate_target_health = true
    name                   = aws_api_gateway_domain_name.acme-api-gw-domain.regional_domain_name
    zone_id                = aws_api_gateway_domain_name.acme-api-gw-domain.regional_zone_id
  }
}
resource "aws_api_gateway_base_path_mapping" "acme-api-gw-domain-mapping" {
  api_id      = aws_api_gateway_rest_api.acme-api-gw-rest.id
  stage_name  = aws_api_gateway_stage.acme-api-gw-stage.stage_name
  domain_name = aws_api_gateway_domain_name.acme-api-gw-domain.domain_name
}