What it actually is
AWS PrivateLink lets a client in one VPC call a service in another VPC, another account, or an AWS service, without that traffic touching the internet, and without the two networks being connected at all. The docs describe it as connecting your VPC to services "as if they were in your VPC", and say you need no internet gateway, NAT device, public IP address, Direct Connect connection or Site-to-Site VPN to do it.
The mental model the docs bury: PrivateLink does not connect networks, it publishes a service. VPC peering and Transit Gateway join address spaces, so every host in one can try to reach every host in the other, and you police that with security groups. PrivateLink joins nothing. The consumer creates an interface endpoint, which is just a set of elastic network interfaces with private IPs from the consumer's own subnets. Connections to those IPs are carried by AWS to a Network Load Balancer on the provider side. The provider cannot initiate anything back, cannot see the consumer's CIDR, and the two sides can use overlapping CIDRs because no routes are ever exchanged.
That is why PrivateLink is the default answer to "expose my service to another team, another account or a customer", and why it is also the mechanism behind most AWS service endpoints you already use, such as the ones for Systems Manager, ECR or Secrets Manager.
The model
There are four endpoint types, and confusing them is the most common mistake.
An interface endpoint is the core one: ENIs in your subnets, one per Availability Zone you select, pointing at an AWS service, an endpoint service in another account, or an AWS Marketplace partner service. A gateway endpoint is a different mechanism that only exists for Amazon S3 and DynamoDB: it is a route table target, not an ENI, and it is free. A Gateway Load Balancer endpoint steers traffic to a fleet of virtual appliances behind a Gateway Load Balancer. The newest are resource endpoints and service network endpoints, which reach a database or other resource in another VPC through a resource configuration, or a VPC Lattice service network. On September 18, 2026 AWS added a fifth, the tunnel endpoint, which uses GENEVE encapsulation to reach a whole CIDR range shared through AWS Resource Access Manager instead of one resource at a time.
On the provider side the object is an endpoint service: a configuration that points at one or more Network Load Balancers (or a Gateway Load Balancer) and says who may connect. By default nobody can. You add allowed principals as account root ARNs, role ARNs, user ARNs, or *. You choose whether each connection request needs manual acceptance. And you can associate one private DNS name with the service.
Traffic flow, from the endpoint service docs: the consumer creates the interface endpoint and picks subnets, AWS creates an endpoint ENI in each, requests to the Regional DNS name resolve to a healthy ENI chosen round robin across Availability Zones, and the NLB receives the request and forwards it to a target. A zonal DNS name pins traffic to one AZ. The service is available only in the Availability Zones enabled on the provider's NLB, and consumers can only create endpoints in those zones, which is the root of many "why can't I pick that subnet" questions.
Who operates what: AWS runs the data path and its scaling. You operate the NLB and the targets (provider), the endpoint security groups, subnets and DNS (consumer), and the allow list and acceptance decisions (provider). Removing a permission does not tear down connections that were already accepted, which matters when you revoke a customer.
When to use it, when not to
PrivateLink | VPC peering | Transit Gateway | |
|---|---|---|---|
What it connects | A client to one service | Two networks | Many networks |
Direction | Consumer to provider only | Both ways | Both ways |
Overlapping CIDRs | Allowed | Not allowed | Not allowed |
Blast radius | The one service you exposed | The whole address space | Whatever the route tables allow |
Pricing shape | Endpoint-hours plus per-GB | No hourly charge | Attachment-hours plus per-GB |
Reachable from on-premises | Yes, over VPN or Direct Connect | Not transitively | Yes |
Use PrivateLink when you are the provider of a service consumed by many accounts, including ones you do not control, when the consumers' CIDRs may overlap with yours or each other, or when you want the narrowest possible exposure. Use it for AWS services from private subnets with no NAT. Use it to give on-premises networks private access to S3 or other services, since the S3 documentation notes that gateway endpoints do not work for traffic from on-premises, peered VPCs in other Regions or transit gateways, while interface endpoints do.
Do not use it when you need bidirectional, any-to-any connectivity, because PrivateLink is strictly client to service. Do not use an interface endpoint for S3 from inside a single VPC if a gateway endpoint will do, since the gateway endpoint costs nothing. And note the quiet trap in the S3 docs: turning on private DNS for an S3 interface endpoint without scoping it sends your in-VPC S3 traffic through the paid endpoint instead of the free gateway one. The documented fix is private DNS only for the inbound Resolver endpoint, so on-premises uses the interface endpoint and in-VPC uses the gateway endpoint.
What it costs
Pricing, from the PrivateLink pricing page: interface endpoints bill per hour per endpoint ENI (the page's worked example uses $0.01 per hour per ENI, so one endpoint in three AZs is three ENIs), and each partial hour counts as a full hour. Data processing is tiered monthly across all interface endpoints in a Region: $0.01 per GB for the first 1 PB, $0.006 per GB for the next 4 PB, $0.004 per GB beyond 5 PB. Resource and tunnel endpoints are priced separately; the page's example uses $0.02 per resource per hour with the same per-GB tiers. Check your Region, because the page's examples are for US East.
The surprise is the multiplier. One endpoint is not one meter, it is one meter per AZ. A service consumed through endpoints in three AZs costs 3 x $0.01 x 730 = $21.90 a month before a single byte moves. Multiply by the number of AWS services you front with endpoints and by the number of VPCs, and the fixed bill becomes visible: twelve services across three AZs is 36 ENIs, about $263 a month per VPC at the example rate before any data. Weigh that against a NAT gateway for the same traffic, and treat it as a security and egress decision first and a cost decision second.
Cross-Region access has its own meters. Per the pricing page, the consumer pays standard endpoint charges plus inter-Region data transfer, and the provider pays a fixed hourly charge per active remote Region, with the example using $0.05 per hour per remote Region. The page's worked example shows an endpoint in US East (N. Virginia) reaching a service in US West (Oregon): $0.05 processing plus $0.10 transfer for 5 GB, plus the endpoint ENI hours.
You also pay for the provider's Network Load Balancer, which is billed under Elastic Load Balancing pricing, not here. Gateway endpoints for S3 and DynamoDB are free. I found no free tier on the PrivateLink pricing page.
The limits that bite
From the endpoint quotas page: 50 interface and Gateway Load Balancer endpoints per VPC combined (adjustable), 20 gateway endpoints per Region and up to 255 per VPC (adjustable), 200 resource endpoints per VPC, 50 service network endpoints per VPC, and 20,480 characters per endpoint policy, which is not adjustable.
Throughput is per AZ. An endpoint gets up to 10 Gbps per Availability Zone by default and scales automatically up to 100 Gbps per AZ, so the ceiling is the number of AZs times 100 Gbps; beyond that you contact AWS Support. MTU is 8500 bytes, and packets larger than that are dropped. Path MTU Discovery is not supported: endpoints do not send the ICMP "fragmentation needed" message, and MSS clamping is enforced on every packet. If a large transfer stalls while small calls work, this is the first suspect.
EC2 API calls are throttled per account and also per AWS Organization, so a RequestLimitExceeded while you are under your own limits means another account in the organization is hammering the same APIs.
Several limits are not numbers. Interface endpoints do not answer ping; use nc. Each subnet loses its first four addresses and its last one to AWS, which matters in small subnets. You cannot create or modify endpoints in subnets shared with you. A service is only reachable in the AZs its NLB covers. Cross-Region access is not supported in AZs use1-az3, usw1-az2, apne1-az3, apne2-az2 and apne2-az4, nor for an NLB with a custom TCP idle timeout, nor with UDP fragmentation, and PrivateLink fails over between AZs but not between Regions. An endpoint service can have only one private DNS name, and private DNS names are not supported for Gateway Load Balancer endpoints.
Build it: a service in a VPC with no way out
We build two VPCs. The provider VPC has an internal NLB, one instance serving a one-line HTTP response on port 8080, and nothing else: no internet gateway, no NAT, no peering. The consumer VPC has an interface endpoint and one instance that calls the service and writes the answer to its console log, so we need no SSH and no extra endpoints for Systems Manager. If both sides share one account, the allowed principal is that account's root ARN; for a real cross-account run, use two CLI profiles.
Prerequisites: Python 3.10 or later, boto3, credentials for a sandbox account, and a Region with at least two AZs. IAM permissions for the identity running the script, scoped to a sandbox:
{
"Version": "2012-10-17",
"Statement": [{
"Effect": "Allow",
"Action": [
"ec2:CreateVpc", "ec2:DeleteVpc", "ec2:ModifyVpcAttribute", "ec2:DescribeVpcs",
"ec2:CreateSubnet", "ec2:DeleteSubnet", "ec2:DescribeSubnets",
"ec2:CreateSecurityGroup", "ec2:DeleteSecurityGroup",
"ec2:AuthorizeSecurityGroupIngress", "ec2:DescribeSecurityGroups",
"ec2:RunInstances", "ec2:TerminateInstances", "ec2:DescribeInstances",
"ec2:GetConsoleOutput", "ec2:DescribeInternetGateways",
"ec2:CreateVpcEndpointServiceConfiguration", "ec2:DeleteVpcEndpointServiceConfigurations",
"ec2:ModifyVpcEndpointServicePermissions", "ec2:AcceptVpcEndpointConnections",
"ec2:DescribeVpcEndpointConnections", "ec2:DescribeVpcEndpointServiceConfigurations",
"ec2:CreateVpcEndpoint", "ec2:DeleteVpcEndpoints", "ec2:DescribeVpcEndpoints",
"ec2:CreateTags", "ec2:DescribeAvailabilityZones",
"elasticloadbalancing:*", "ssm:GetParameter"
],
"Resource": "*"
}]
}Step one is the provider side. This creates the VPC, two private subnets, the NLB, a target group, and an instance that serves a file. The AMI comes from the public Amazon Linux 2023 SSM parameter, and Python 3 ships on the standard image (verify on your AMI version), so the instance needs no internet to run its web server.
import time, boto3
REGION = "us-east-1"
ec2 = boto3.client("ec2", region_name=REGION)
elb = boto3.client("elbv2", region_name=REGION)
ssm = boto3.client("ssm", region_name=REGION)
state = {}
azs = [z["ZoneName"] for z in ec2.describe_availability_zones(
Filters=[{"Name": "state", "Values": ["available"]}])["AvailabilityZones"]][:2]
ami = ssm.get_parameter(
Name="/aws/service/ami-amazon-linux-latest/al2023-ami-kernel-default-x86_64"
)["Parameter"]["Value"]
def make_vpc(cidr, name):
vpc = ec2.create_vpc(CidrBlock=cidr, TagSpecifications=[{"ResourceType": "vpc",
"Tags": [{"Key": "Name", "Value": name}, {"Key": "lab", "Value": "privatelink"}]}])["Vpc"]["VpcId"]
ec2.get_waiter("vpc_available").wait(VpcIds=[vpc])
for attr in ("EnableDnsSupport", "EnableDnsHostnames"):
ec2.modify_vpc_attribute(VpcId=vpc, **{attr: {"Value": True}})
return vpc
def make_subnets(vpc, base):
ids = []
for i, az in enumerate(azs):
ids.append(ec2.create_subnet(VpcId=vpc, CidrBlock=f"{base}.{i}.0/24",
AvailabilityZone=az)["Subnet"]["SubnetId"])
return ids
# --- Provider VPC: no IGW, no NAT, no peering ---
state["pvpc"] = make_vpc("10.10.0.0/16", "pl-provider")
state["psubs"] = make_subnets(state["pvpc"], "10.10")
sg = ec2.create_security_group(GroupName="pl-provider-targets", Description="targets",
VpcId=state["pvpc"])["GroupId"]
ec2.authorize_security_group_ingress(GroupId=sg, IpPermissions=[{
"IpProtocol": "tcp", "FromPort": 8080, "ToPort": 8080,
"IpRanges": [{"CidrIp": "10.10.0.0/16"}]}])
state["psg"] = sg
user_data = """#!/bin/bash
mkdir -p /srv && cd /srv
echo "hello-from-the-provider-vpc" > index.html
nohup python3 -m http.server 8080 >/var/log/web.log 2>&1 &
"""
inst = ec2.run_instances(ImageId=ami, InstanceType="t3.micro", MinCount=1, MaxCount=1,
SubnetId=state["psubs"][0], SecurityGroupIds=[sg], UserData=user_data,
TagSpecifications=[{"ResourceType": "instance",
"Tags": [{"Key": "lab", "Value": "privatelink"}]}])["Instances"][0]["InstanceId"]
state["pinst"] = inst
ec2.get_waiter("instance_running").wait(InstanceIds=[inst])
lb = elb.create_load_balancer(Name="pl-lab-nlb", Type="network", Scheme="internal",
Subnets=state["psubs"])["LoadBalancers"][0]
state["nlb"] = lb["LoadBalancerArn"]
tg = elb.create_target_group(Name="pl-lab-tg", Protocol="TCP", Port=8080,
VpcId=state["pvpc"], TargetType="instance")["TargetGroups"][0]
state["tg"] = tg["TargetGroupArn"]
elb.register_targets(TargetGroupArn=state["tg"], Targets=[{"Id": inst, "Port": 8080}])
elb.create_listener(LoadBalancerArn=state["nlb"], Protocol="TCP", Port=8080,
DefaultActions=[{"Type": "forward", "TargetGroupArn": state["tg"]}])
elb.get_waiter("load_balancer_available").wait(LoadBalancerArns=[state["nlb"]])Step two turns the NLB into an endpoint service, requires acceptance, and allows exactly one principal. The call is CreateVpcEndpointServiceConfiguration and the permission call is ModifyVpcEndpointServicePermissions; the principal ARN formats come from the docs. We use the caller's own account root here.
sts = boto3.client("sts", region_name=REGION)
account = sts.get_caller_identity()["Account"]
svc = ec2.create_vpc_endpoint_service_configuration(
NetworkLoadBalancerArns=[state["nlb"]],
AcceptanceRequired=True,
SupportedIpAddressTypes=["ipv4"],
)["ServiceConfiguration"]
state["svc_id"] = svc["ServiceId"]
state["svc_name"] = svc["ServiceName"]
ec2.modify_vpc_endpoint_service_permissions(
ServiceId=state["svc_id"],
AddAllowedPrincipals=[f"arn:aws:iam::{account}:root"],
)
print("Service name:", state["svc_name"])Step three is the consumer side: its own VPC (a different CIDR, though overlapping would work too), an endpoint security group that admits port 8080 from the consumer CIDR, and the interface endpoint. The endpoint lands in pendingAcceptance because we required acceptance, and stays there until the provider accepts.
state["cvpc"] = make_vpc("10.20.0.0/16", "pl-consumer")
state["csubs"] = make_subnets(state["cvpc"], "10.20")
esg = ec2.create_security_group(GroupName="pl-endpoint", Description="endpoint ENIs",
VpcId=state["cvpc"])["GroupId"]
ec2.authorize_security_group_ingress(GroupId=esg, IpPermissions=[{
"IpProtocol": "tcp", "FromPort": 8080, "ToPort": 8080,
"IpRanges": [{"CidrIp": "10.20.0.0/16"}]}])
state["esg"] = esg
ep = ec2.create_vpc_endpoint(VpcId=state["cvpc"], VpcEndpointType="Interface",
ServiceName=state["svc_name"], SubnetIds=state["csubs"],
SecurityGroupIds=[esg])["VpcEndpoint"]
state["ep_id"] = ep["VpcEndpointId"]
# Provider accepts the pending connection
time.sleep(10)
ec2.accept_vpc_endpoint_connections(ServiceId=state["svc_id"],
VpcEndpointIds=[state["ep_id"]])
while True:
ep = ec2.describe_vpc_endpoints(VpcEndpointIds=[state["ep_id"]])["VpcEndpoints"][0]
print("endpoint state:", ep["State"])
if ep["State"] == "available":
break
time.sleep(15)
dns = ep["DnsEntries"][0]["DnsName"]
print("Endpoint DNS:", dns)Step four is the verification. The consumer instance runs in a subnet with no route to anywhere except its own VPC. Its user data calls the endpoint's DNS name and writes the response to the console, which we read back through the EC2 API.
client_ud = f"""#!/bin/bash
sleep 20
echo "PRIVATELINK_RESULT: $(curl -s -m 10 http://{dns}:8080/ | head -c 80)" > /dev/console
"""
csg = ec2.create_security_group(GroupName="pl-client", Description="client",
VpcId=state["cvpc"])["GroupId"]
state["csg"] = csg
cinst = ec2.run_instances(ImageId=ami, InstanceType="t3.micro", MinCount=1, MaxCount=1,
SubnetId=state["csubs"][0], SecurityGroupIds=[csg], UserData=client_ud,
TagSpecifications=[{"ResourceType": "instance",
"Tags": [{"Key": "lab", "Value": "privatelink"}]}])["Instances"][0]["InstanceId"]
state["cinst"] = cinst
ec2.get_waiter("instance_running").wait(InstanceIds=[cinst])
for _ in range(20):
out = ec2.get_console_output(InstanceId=cinst, Latest=True).get("Output", "")
hit = [l for l in out.splitlines() if "PRIVATELINK_RESULT" in l]
if hit:
print(hit[-1]); break
time.sleep(30)
# Prove there is no internet path on either side
for vpc in (state["pvpc"], state["cvpc"]):
igws = ec2.describe_internet_gateways(
Filters=[{"Name": "attachment.vpc-id", "Values": [vpc]}])["InternetGateways"]
print(vpc, "internet gateways:", len(igws))Expected result: a line reading PRIVATELINK_RESULT: hello-from-the-provider-vpc, and internet gateways: 0 for both VPCs. That pairing is the proof: the answer came from a service in a VPC that has no internet gateway, to a client in a VPC that has none either, and the two VPCs have no peering or routes between them. A useful extra check is aws ec2 describe-vpc-endpoint-connections --filters Name=service-id,Values=<service id>, which should list the connection as available.
One honest caveat: I wrote this script against the current AWS documentation and API references but did not execute it, because this run had no AWS account. The API calls and parameters are the documented ones, but expect to adjust a name or a wait on your first pass, and run it in a sandbox account.
Cost of following along, for about an hour: two endpoint ENIs at the page's example $0.01 per ENI-hour is about $0.02 per hour, data processing is a rounding error for one curl, and the NLB plus two t3.micro instances are billed under Elastic Load Balancing and EC2 pricing. Budget under a dollar, and check the live ELB and EC2 pricing for your Region before you start.
Making the service feel like your own domain
By default consumers use the generated names, which look like endpoint_id.endpoint_service_id.service_region.vpce.amazonaws.com. To let consumers keep a friendly name, associate a private DNS name with the service. Before they can use it you must prove you own the domain with a TXT record: read the name and value from describe-vpc-endpoint-service-configurations, publish the record (the docs suggest a TTL of 1800 in Route 53), and wait for the status to move from pendingVerification to verified. DNS can take up to 48 hours to propagate, though it is usually much faster. If the status later changes away from verified, new connection requests are denied, but existing connections are unaffected.
When it breaks
The endpoint sits in pendingAcceptance forever. The service requires acceptance and nobody accepted. Run accept-vpc-endpoint-connections on the provider side, or turn acceptance off with modify-vpc-endpoint-service-configuration. If you cannot even create the endpoint, the consumer principal is not on the allowed list.
The connection times out. Check in this order: the endpoint security group allows the port from the client; the NLB target group health check passes (the NLB will not forward to unhealthy targets); the target security group allows the NLB's traffic; the network ACLs on the endpoint subnets allow both directions. If the NLB has its own security group, it must allow inbound traffic from client IPs, or you must turn off evaluation of inbound security group rules for PrivateLink traffic.
Small calls work, big transfers stall. The 8500 byte MTU with no Path MTU Discovery. Endpoints drop oversize packets and never tell the sender. Lower the MTU on the client or the path.
A subnet is missing from the list when creating the endpoint. The provider's NLB is not enabled in that AZ, so the service is not offered there. AZ names differ per account, so compare AZ IDs, not names, when the two accounts differ.
Private DNS does nothing. Both enableDnsSupport and enableDnsHostnames must be on in the consumer VPC. For AWS services with private DNS enabled, SDK calls to the public hostname resolve to the endpoint ENIs, so a second resolver or a conflicting private hosted zone can override that.
The provider cannot see client addresses. Traffic arrives from the endpoint, not from the consumer's real IP. If your service needs the source address, that is a load balancer feature to configure (the endpoint service docs mention Proxy Protocol v2 in the IPv6 context), and you must check the NLB documentation for the exact behavior of your target type before relying on it.
Cross-Region silently unavailable. Check the unsupported AZ list, the custom TCP idle timeout on the NLB, and that you hold vpce:AllowMultiRegion.
Cleanup
Order matters, because the endpoint service cannot go while connections exist and subnets cannot go while ENIs exist. Delete the consumer endpoint first, then the service, then the load balancer, then the VPCs.
def wait_gone(fn, interval=10, tries=60):
for _ in range(tries):
if fn(): return
time.sleep(interval)
ec2.terminate_instances(InstanceIds=[state["cinst"], state["pinst"]])
ec2.get_waiter("instance_terminated").wait(InstanceIds=[state["cinst"], state["pinst"]])
ec2.delete_vpc_endpoints(VpcEndpointIds=[state["ep_id"]])
wait_gone(lambda: not ec2.describe_vpc_endpoints(
Filters=[{"Name": "vpc-endpoint-id", "Values": [state["ep_id"]]},
{"Name": "vpc-endpoint-state", "Values": ["available", "pending", "deleting"]}]
)["VpcEndpoints"])
ec2.delete_vpc_endpoint_service_configurations(ServiceIds=[state["svc_id"]])
elb.delete_load_balancer(LoadBalancerArn=state["nlb"])
elb.get_waiter("load_balancers_deleted").wait(LoadBalancerArns=[state["nlb"]])
time.sleep(30)
elb.delete_target_group(TargetGroupArn=state["tg"])
for sg_id in (state["csg"], state["esg"], state["psg"]):
ec2.delete_security_group(GroupId=sg_id)
for sid in state["csubs"] + state["psubs"]:
ec2.delete_subnet(SubnetId=sid)
ec2.delete_vpc(VpcId=state["cvpc"])
ec2.delete_vpc(VpcId=state["pvpc"])Confirm the bill is zero by checking three things: aws ec2 describe-vpc-endpoints and describe-vpc-endpoint-service-configurations return nothing for the lab, aws elbv2 describe-load-balancers shows no pl-lab-nlb, and aws ec2 describe-instances --filters Name=tag:lab,Values=privatelink shows only terminated instances. If a delete fails with a dependency error, an ENI is still attached; wait a minute and retry, as endpoint and load balancer ENIs are released asynchronously.
The decision in one paragraph
Reach for PrivateLink when the thing you are protecting is a service, not a network. It gives the consumer a private IP and the provider a one-way door, it tolerates overlapping CIDRs, and it scales to many consumers without any route table work. Pay attention to the per-AZ endpoint meter, use free gateway endpoints for S3 and DynamoDB inside a single VPC, and design for the 8500 byte MTU. For the broader picture of how these pieces fit together, the Transit Gateway article in this series covers the network-joining alternative.

