Private API Gateway with VPC Link
Routing multiple backend services through a single API Gateway using a shared ALB, VPC Link, and header-based routing — with AWS CDK.
- DATE:
- MAY.12.2025
- READ:
- 18 MIN
The problem with public endpoints
The default API Gateway integration is simple: HTTP API → public URL of your backend. But this means your backend must be internet-accessible. Every request traverses the public internet, your backend needs its own TLS termination, and your attack surface includes every port exposed on a public subnet.
The alternative is a VPC Link — API Gateway creates elastic network interfaces (ENIs) inside your VPC’s private subnets. Traffic flows from API Gateway through these ENIs to your internal load balancer, never leaving AWS’s network. Your backends stay in private subnets with no public IP, no internet gateway dependency, and no direct exposure.
The second problem is cost. If you have five microservices, the naive approach creates five ALBs — each costing ~$16/month plus data processing charges. A shared internal ALB with header-based routing serves all five services through a single load balancer.
The architecture
Three CDK stacks compose this pattern:
- SharedVpcLinkStack — creates the VPC Link and stores its ID in SSM Parameter Store
- SharedAlbStack — creates a single internal ALB with WAF, access logging, and a default listener
- PrivateFastApiStack (and siblings) — each service registers a listener rule on the shared ALB and creates its own HTTP API pointing through the VPC Link
The key insight: API Gateway injects an X-Target-Service header into every request. The ALB listener evaluates these headers to route to the correct target group. No DNS proliferation, no path-prefix conflicts, no separate load balancers.
Step 1: the VPC Link
The VPC Link is the bridge. It places ENIs in your private subnets so API Gateway can reach internal resources.
from aws_cdk import aws_apigatewayv2 as apigwv2
from aws_cdk import aws_ec2 as ec2
from aws_cdk import aws_ssm as ssm
vpc_link_eni_sg = ec2.SecurityGroup(
self, "SharedVpcLinkEniSg",
vpc=vpc,
description="SG for shared VPC Link ENIs",
allow_all_outbound=True,
)
shared_vpc_link = apigwv2.VpcLink(
self, "SharedVpcLinkInstance",
vpc=vpc,
subnets=ec2.SubnetSelection(
subnet_type=ec2.SubnetType.PRIVATE_WITH_EGRESS
),
security_groups=[vpc_link_eni_sg],
vpc_link_name="shared-backend-apis-vpc-link",
)
# Store the ID for cross-stack reference
ssm.StringParameter(
self, "SharedVpcLinkIdSsmParam",
parameter_name="/acmecorp/shared/vpc-link-id",
string_value=shared_vpc_link.vpc_link_id,
) The VPC Link ID is stored in SSM Parameter Store rather than using CloudFormation exports. SSM parameters are mutable and don’t create hard cross-stack dependencies that prevent deletion.
Step 2: the shared ALB
One internal ALB serves all services. It’s never exposed to the internet.
from aws_cdk import aws_elasticloadbalancingv2 as elbv2
from aws_cdk import Duration
shared_alb = elbv2.ApplicationLoadBalancer(
self, "SharedQaAlb",
vpc=vpc,
internet_facing=False,
security_group=shared_alb_sg,
load_balancer_name="shared-qa-services-alb",
vpc_subnets=ec2.SubnetSelection(
subnet_type=ec2.SubnetType.PRIVATE_WITH_EGRESS
),
idle_timeout=Duration.seconds(60),
)
# Access logs for compliance
shared_alb.log_access_logs(elb_access_logs_bucket, prefix="shared-qa-alb")
# WAF attachment
wafv2.CfnWebACLAssociation(
self, "SharedQaAlbWafAssociation",
resource_arn=shared_alb.load_balancer_arn,
web_acl_arn=waf_acl_arn,
) The ALB gets a default listener that returns 404 — if no header rule matches, the request is rejected. This is the catch-all safety net.
shared_listener = shared_alb.add_listener(
"SharedHttpListener",
port=80,
protocol=elbv2.ApplicationProtocol.HTTP,
default_action=elbv2.ListenerAction.fixed_response(
status_code=404,
content_type="text/plain",
message_body="Not Found — no matching service header",
),
) Private DNS for service-to-service calls
Services that need to call each other directly (bypassing API Gateway) use a private hosted zone:
from aws_cdk import aws_route53 as route53
from aws_cdk import aws_route53_targets as targets
private_zone = route53.PrivateHostedZone(
self, "InternalPrivateZone",
zone_name="internal.acmecorp.com",
vpc=vpc,
)
route53.ARecord(
self, "GotenbergInternalDns",
zone=private_zone,
record_name="gotenberg",
target=route53.RecordTarget.from_alias(
targets.LoadBalancerTarget(shared_alb),
),
) Now gotenberg.internal.acmecorp.com resolves to the ALB inside the VPC — no internet hop.
Step 3: service registration
Each service creates its own HTTP API, target group, and listener rule. The critical piece is the HTTP_PROXY integration through the VPC Link.
# Create the HTTP API
http_api = apigwv2.HttpApi(
self, "FastApiHttpApi",
api_name="fastapi-http-api-qa",
create_default_stage=False,
cors_preflight=apigwv2.CorsPreflightOptions(
allow_origins=["https://app.acmecorp.com"],
allow_methods=[apigwv2.CorsHttpMethod.ANY],
allow_headers=["*"],
allow_credentials=True,
max_age=Duration.days(10),
),
)
# Integration: HTTP_PROXY through VPC Link
integration = apigwv2.CfnIntegration(
self, "FastApiAlbIntegration",
api_id=http_api.http_api_id,
integration_type="HTTP_PROXY",
integration_uri=shared_alb_listener_arn,
integration_method="ANY",
connection_type="VPC_LINK",
connection_id=vpc_link_id,
payload_format_version="1.0",
request_parameters={
"overwrite:header.X-Target-Service": "fastapi-qa"
},
) The request_parameters line is the routing mechanism. API Gateway injects X-Target-Service: fastapi-qa into every request before forwarding it through the VPC Link.
Listener rules match the header
elbv2.ApplicationListenerRule(
self, "FastApiListenerRule",
listener=shared_listener,
priority=2,
conditions=[
elbv2.ListenerCondition.http_header(
"X-Target-Service", ["fastapi-qa"]
)
],
action=elbv2.ListenerAction.forward([target_group]),
) Each service gets a unique priority. The ALB evaluates rules in priority order — first match wins. The header values are service-specific strings that never overlap.
+--------------------+--------------------+----------+--------------------+ | Service | Header value | Priority | Domain | +--------------------+--------------------+----------+--------------------+ | FastAPI (prod) | fastapi-app | 2 | *.api.py.acmecorp.com | +--------------------+--------------------+----------+--------------------+ | Loopback (prod) | loopback-app | 3 | *.api.lp.acmecorp.com | +--------------------+--------------------+----------+--------------------+ | ZeroPoint (prod) | zeropoint-app | 4 | *.api.auth.acmecorp.com | +--------------------+--------------------+----------+--------------------+ | FastAPI | fastapi-prerelease | 11 | *.api.py.acmecorp.com | | (prerelease) | | | | +--------------------+--------------------+----------+--------------------+ | Gantt Scheduler | gantt-autoscheduler-qa | 15 | *.api.autos.acmecorp.com | +--------------------+--------------------+----------+--------------------+
ACM certificates for custom domains
Each HTTP API gets a custom domain backed by an ACM certificate with DNS validation:
from aws_cdk import aws_certificatemanager as acm
hosted_zone = route53.HostedZone.from_lookup(
self, "AcmeCorpHostedZone",
domain_name="acmecorp.com",
)
api_domain_names = [
"*.api.py.acmecorp.com",
"*.api.lp.acmecorp.com",
"*.api.auth.acmecorp.com",
"*.api.autos.acmecorp.com",
]
certificate = acm.Certificate(
self, "AcmeCorpAPIDomainsCertificate",
domain_name=api_domain_names[0],
subject_alternative_names=api_domain_names,
validation=acm.CertificateValidation.from_dns(hosted_zone),
) CertificateValidation.from_dns(hosted_zone) tells CDK to automatically create the CNAME validation records in Route 53. No manual DNS clicks, no waiting — the certificate validates during stack deployment.
The certificate ARN is exported via CfnOutput so service stacks can reference it:
CfnOutput(
self, "CertificateArnOutput",
value=certificate.certificate_arn,
export_name="AcmeCorpAPIDomainsCertificateArn",
) API Gateway stage and domain mapping
The HTTP API needs a stage and a domain name mapping:
# Create a stage with throttling and logging
stage = apigwv2.CfnStage(
self, "FastApiStage",
api_id=http_api.http_api_id,
stage_name="$default",
auto_deploy=True,
default_route_settings=apigwv2.CfnStage.RouteSettingsProperty(
throttling_rate_limit=10000,
throttling_burst_limit=5000,
detailed_metrics_enabled=True,
),
access_log_settings=apigwv2.CfnStage.AccessLogSettingsProperty(
destination_arn=log_group.log_group_arn,
),
)
# Custom domain
domain_name = apigwv2.CfnDomainName(
self, "FastApiDomainName",
domain_name="qa.api.py.acmecorp.com",
domain_name_configurations=[
apigwv2.CfnDomainName.DomainNameConfigurationProperty(
certificate_arn=certificate_arn,
endpoint_type="REGIONAL",
)
],
)
# Map the domain to the API
apigwv2.CfnApiMapping(
self, "FastApiDomainMapping",
api_id=http_api.http_api_id,
domain_name=domain_name.ref,
stage="$default",
) Then a Route 53 alias record points the domain to the API Gateway:
route53.ARecord(
self, "FastApiDnsRecord",
zone=hosted_zone,
record_name="qa.api.py",
target=route53.RecordTarget.from_alias(
targets.ApiGatewayv2DomainProperties(
regional_domain_name=domain_name.attr_regional_domain_name,
regional_hosted_zone_id=domain_name.attr_regional_hosted_zone_id,
)
),
) The cost picture
+--------------------+--------------------+--------------------+ | Resource | Per-service ALB | Shared ALB | +--------------------+--------------------+--------------------+ | ALB fixed cost | $16/mo × 5 = | $16/mo × 1 = | | | $80/mo | $16/mo | +--------------------+--------------------+--------------------+ | VPC Link | N/A | $0.01/hr = ~$7/mo | +--------------------+--------------------+--------------------+ | API Gateway | $1/million | $1/million | | | requests | requests | +--------------------+--------------------+--------------------+ | Total (5 services) | ~$80/mo + data | ~$23/mo + data | +--------------------+--------------------+--------------------+
The shared ALB pattern saves ~$57/month for five services. The savings scale linearly — ten services save ~$137/month. The VPC Link costs $0.01/hour regardless of how many services use it.
Security model
The architecture has four layers of defense:
- API Gateway — TLS termination, CORS enforcement, throttling, request validation
- VPC Link security group — controls which resources the ENIs can reach
- ALB security group — only accepts traffic from VPC Link ENIs
- WAF — SQL injection, XSS, rate limiting rules on the ALB
The backends have no public IP. There’s no path from the internet to the Fargate tasks that doesn’t go through all four layers. Even if API Gateway were compromised, the VPC Link security group limits lateral movement to the ALB’s security group.
What makes this pattern work
The header injection trick is the linchpin. Without it, you’d need path-based routing (/fastapi/*, /loopback/*) which requires rewriting application routes, or host-based routing which requires separate DNS records per service pointing to the ALB — defeating the purpose of the shared architecture.
With header injection, the application doesn’t know it’s behind a shared ALB. It receives requests at its natural paths (/healthz, /api/v1/users). The routing is invisible to the backend code. Adding a new service means:
- Create a target group
- Add a listener rule with a new header value and priority
- Create an HTTP API with the header injection
- Point a DNS record at the API
No changes to existing services. No ALB reconfiguration. No path conflicts.
Traffic never leaves the VPC. The backend never touches the internet. The ALB is shared. The routing is invisible. That’s the architecture.