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
- Two AWS regions already set up with their own VPCs, private subnets, and
Lambda functions. I’ll call them
euw1anduse1below. - Terraform with the AWS provider at 5.83 or newer. Cross-region PrivateLink support landed in that version.
- 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:
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:
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:
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:
❯ 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-0f1e2d3c4b5a69788Same 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.