Omegion

How to Reach a Lambda in Another AWS Region Without VPC Peering

Introduction

Our infra isolates AWS regions on purpose. Nothing in eu-west-1 talks directly to anything in us-east-1, and nothing routes back the other way. It keeps a bad deploy or a bad actor in one region from becoming a problem in both. A team came to me needing exactly the thing that policy exists to prevent: a management app running in eu-west-1 had to call services that only exist in us-east-1. It was a one-time need for one app, not a reason to open the regions up to each other generally, so VPC peering or a Transit Gateway were both off the table. I needed a path that was narrow enough to still call it isolation.

Prerequisites

  1. Two AWS regions already set up with their own VPCs, private subnets, and Lambda functions. I’ll call them euw1 and use1 below.
  2. Terraform with the AWS provider at 5.83 or newer. Cross-region PrivateLink support landed in that version.
  3. Comfort with VPC endpoints, NLBs, and ALBs. I won’t re-explain what those are.

The shape of the fix

AWS PrivateLink lets a VPC endpoint in one region connect to an endpoint service in another, over AWS’s own network, no peering or internet exposure involved. That’s the narrow path: one interface endpoint in euw1, pointed at one endpoint service in use1, carrying traffic for exactly this app and nothing else.

An endpoint service has to sit behind a Network Load Balancer, and an NLB can’t target a Lambda function directly, only instances, IPs, or an ALB. So the US side ends up as a small stack: NLB in front, forwarding to an ALB target group, which is the piece that’s actually allowed to invoke Lambda.

Setting up the US side

Two providers, one per region, since everything below is split across them:

hcl
provider "aws" {
  alias  = "use1"
  region = "us-east-1"
}

provider "aws" {
  alias  = "euw1"
  region = "eu-west-1"
}

The NLB, its ALB-type target group, and the endpoint service that sits in front of it:

hcl
resource "aws_lb_target_group" "nlb_to_alb" {
  provider    = aws.use1
  name        = "us-services-nlb-tg"
  target_type = "alb"
  port        = 443
  protocol    = "TCP"
  vpc_id      = var.use1_vpc_id
}

resource "aws_lb_target_group_attachment" "nlb_to_alb" {
  provider          = aws.use1
  target_group_arn  = aws_lb_target_group.nlb_to_alb.arn
  target_id         = aws_lb.us_internal_alb.arn
  port              = 443
}

resource "aws_lb" "us_nlb" {
  provider           = aws.use1
  name               = "us-services-nlb"
  internal           = true
  load_balancer_type = "network"
  subnets            = var.use1_private_subnet_ids
}

resource "aws_vpc_endpoint_service" "us_services" {
  provider                   = aws.use1
  acceptance_required        = true
  network_load_balancer_arns = [aws_lb.us_nlb.arn]
  supported_regions          = ["eu-west-1"]
}

us_internal_alb is the ALB with a lambda-type target group in front of the US Lambda. I’m leaving that part out since it’s just the standard ALB-to-Lambda setup, nothing specific to the region-crossing part.

Setting up the EU side

The interface endpoint is the only new thing on this side, and it needs service_region set to tell it the service lives elsewhere:

hcl
resource "aws_security_group" "us_services_endpoint" {
  provider = aws.euw1
  name     = "us-services-endpoint-sg"
  vpc_id   = var.euw1_vpc_id

  ingress {
    from_port       = 443
    to_port         = 443
    protocol        = "tcp"
    security_groups = [aws_security_group.euw1_lambda.id]
  }
}

resource "aws_vpc_endpoint" "us_services" {
  provider            = aws.euw1
  vpc_id              = var.euw1_vpc_id
  service_name        = aws_vpc_endpoint_service.us_services.service_name
  service_region      = "us-east-1"
  vpc_endpoint_type   = "Interface"
  subnet_ids          = var.euw1_private_subnet_ids
  security_group_ids  = [aws_security_group.us_services_endpoint.id]
  private_dns_enabled = false
}

I left private_dns_enabled off. Private DNS on a cross-region endpoint means resolving the target service’s own domain name to the local endpoint, and I didn’t want to lean on that working the same as it does in-region. The EU Lambda calls the endpoint’s own generated DNS name directly instead, which is one extra environment variable, not a real cost.

The endpoint connection needs to be accepted on the use1 side before traffic flows, because acceptance_required is true above:

shell
❯ aws ec2 describe-vpc-endpoint-connections --region us-east-1 \
  --filters Name=vpc-endpoint-state,Values=pendingAcceptance
{
    "VpcEndpointConnections": [
        {
            "ServiceId": "vpce-svc-0a1b2c3d4e5f6a7b8",
            "VpcEndpointId": "vpce-0f1e2d3c4b5a69788",
            "VpcEndpointOwner": "123456789012",
            "VpcEndpointState": "pendingAcceptance"
        }
    ]
}

❯ aws ec2 accept-vpc-endpoint-connections --region us-east-1 \
  --service-id vpce-svc-0a1b2c3d4e5f6a7b8 \
  --vpc-endpoint-ids vpce-0f1e2d3c4b5a69788

Same account on both ends here, so I did that by hand once. A recurring setup would script it or add the accepter as its own Terraform resource.

Conclusion

The stack is more layers than I’d like for one app calling another region, NLB, ALB, endpoint service, interface endpoint, but each layer is there because something upstream of it can’t do the next hop itself. It stays a narrow, named exception instead of a general path between regions, which was the actual requirement. If this app ever turns into “several apps need this,” I’d stop reaching for one-off endpoints and go read up on Transit Gateway properly instead.