What it actually is
VPC Lattice is a regional, managed layer-7 routing and authorization plane that sits beside your VPCs. You describe services (a listener, rules, target groups), put them in a service network, and associate VPCs with that network. Any client in an associated VPC can then call any service in it by DNS name, and Lattice does the routing, the load balancing and the access check. There is no peering, no transit gateway route and no proxy you operate.
The mental model the docs bury: a service network is not a network in the CIDR sense. It is a namespace plus a policy boundary. Overlapping CIDRs are fine because nothing is routed between your address spaces. Clients reach a link-local data plane address, and Lattice forwards on their behalf. Think of it as an API gateway that every VPC can reach privately, with IAM as the front door.
The model
Per the VPC Lattice user guide, the primitives are these. A service is an independently deployable unit made of target groups, listeners and rules. A target group is a set of targets: EC2 instances, IP addresses, Lambda functions, Application Load Balancers, or Kubernetes pods. A listener checks for connections on a protocol and port. A rule has a priority, conditions and actions. A service network is the logical boundary for a collection of services and resource configurations, and VPCs and services are attached to it through associations. A service directory lists every service you own or that was shared with you through AWS RAM.
Two roles fall out of this. The service owner builds services and associates them. The service network owner, usually a platform team, creates and shares networks and sets coarse access with a network-level auth policy. That split is the point of the product: developers publish without touching network plumbing, platform teams govern without touching application code.
Authorization has two layers. Auth type is NONE or AWS_IAM on both the network and each service. With AWS_IAM, an auth policy is evaluated, and with no policy at all every request is denied. The documented evaluation order is: the caller's IAM identity policy must allow, then the service network policy must allow if its type is AWS_IAM, then the service policy must allow if its type is AWS_IAM. An explicit deny anywhere wins. Callers must sign requests with SigV4 (or SigV4A for multi-Region), using the signing name vpc-lattice-svcs.
Operationally: AWS runs the data plane, load balancing, health checking and the AZ affinity behavior (targets in the client's AZ are preferred, with no inter-AZ transfer charge). You run the targets, the policies and the DNS decisions. Logging can be enabled at the service, resource or network level, to CloudWatch Logs, Firehose or S3.
One newer wrinkle: Lattice also fronts non-service resources (an RDS database, an IP, a domain name) through resource gateways and resource configurations. On 2026-05-04 AWS added private domain-name targets for resource configurations, enabled by setting the resource gateway's Resource Config DNS Resolution to IN_VPC. Auth policies do not apply to resource configurations, so this article stays with services.
When to use it, when not to
You need | Pick | Why |
|---|---|---|
Many VPCs and accounts calling shared HTTP/gRPC services, with per-caller authorization | VPC Lattice | No CIDR coordination, IAM-based access, one policy surface |
One VPC pair, one TCP endpoint, no L7 needs | PrivateLink | Simpler primitive, cheaper to reason about |
Full routed connectivity between many networks, including non-HTTP traffic | Transit Gateway | Lattice only carries what its listeners understand |
Retries, mTLS between pods, traffic shaping inside one cluster | A sidecar mesh (Istio, Linkerd) | Lattice has weighted routing, not a sidecar's feature set |
Public API with throttling and keys | API Gateway | Lattice is for private east-west calls |
The honest summary: Lattice replaces the networking half of a service mesh and part of what people use PrivateLink plus an internal load balancer for. It does not replace Transit Gateway when you need arbitrary routed traffic. If your services speak HTTP, HTTPS, gRPC or TLS passthrough and live in more than a couple of VPCs, it is usually less work than anything else on the list.
What it costs
From the pricing page (US East, N. Virginia examples; rates vary by Region): $0.025 per service per hour, $0.025 per GB of data processed (request plus response, with inter-AZ traffic included), and $0.10 per million HTTP requests or TLS passthrough connections. The first 300,000 requests per hour are free. VPC associations and service network endpoints cost nothing extra. Resource configurations are billed differently: $0.10 per resource-hour, with tiered per-GB rates.
The surprise is the per-service hourly fee. At 730 hours a month a service is about $18.25 before a single byte moves. A platform with 200 small services across a network is about $3,650 a month in service hours alone. The second surprise is that data processing applies even for same-AZ traffic, so a chatty internal API at 10 TB a month adds about $250 (10,240 GB x $0.025). There is no free tier for the hourly or per-GB charges, only for requests. Following along here costs a few cents: one service for an hour plus negligible traffic, plus Lambda and VPC-attached Lambda usage inside the free allowance.
The limits that bite
From the quotas page: listeners per service 2, rules per listener 10, target groups per service 10, targets per target group 1,000, services per Region 2,000, service networks per Region 50, VPC associations per service network 500, security groups per association 5 (not adjustable), auth policy size 10 KB (not adjustable). Most are adjustable except the last two.
The ones that actually hurt: ten rules per listener is tight if you route by path for many microservices on one service, which pushes you toward more services and therefore more hourly fees. A 10 KB auth policy fills up fast if you enumerate principals instead of using conditions like aws:PrincipalOrgID. And the idle timeout defaults to 60 seconds (configurable 60 to 600 via idleTimeoutSeconds on the service), with a hard 10 minute maximum connection duration regardless. Long-polling clients need to reconnect by design. Lambda targets are capped at 6 MB for request and response bodies, and the function must be in the same account and Region as the target group.
Build it: a private orders service behind an IAM auth policy
We will create a Lambda function as the backend, a service network, a service, an auth policy that only allows callers coming from one specific VPC, and a second Lambda inside that VPC that signs and sends a request. Everything is Python 3.12 with boto3. I followed the current API reference for every call, but I did not execute this exact script in an AWS account while writing, so treat the first run as a verification and expect to adjust IDs.
Prerequisites and IAM
You need an AWS account, a default or existing VPC with at least one private-enough subnet, and the AWS CLI configured. The identity running the setup script needs vpc-lattice:* (scope it down after), lambda:CreateFunction, lambda:AddPermission, lambda:CreateAlias, lambda:PublishVersion, iam:PassRole for the two execution roles, ec2:DescribeVpcs, and ec2:CreateSecurityGroup. The client Lambda's execution role needs AWSLambdaVPCAccessExecutionRole, plus an identity-policy allow for vpc-lattice-svcs:Invoke on the service ARN, because the caller's own IAM policy is the first gate.
Step 1: the backend function
The target is a plain Lambda. With event structure V2, the event carries the caller identity that Lattice authenticated, which makes the verification step convincing.
# orders_backend.py
import json
def handler(event, context):
identity = event.get("requestContext", {}).get("identity", {})
body = {
"service": "orders",
"path": event.get("path"),
"method": event.get("method"),
"caller_principal": identity.get("principal"),
"caller_vpc": identity.get("sourceVpcArn"),
}
return {
"isBase64Encoded": False,
"statusCode": 200,
"headers": {"Content-Type": "application/json"},
"body": json.dumps(body),
}
Zip it, create the function with aws lambda create-function, publish a version and create an alias named live. The Lambda target docs recommend registering an alias, not a bare function ARN, so you can swap versions without touching the target group.
Step 2: network, service and routing
Now the Lattice side. Fill in the first four constants.
# lattice_setup.py
import boto3, json
REGION = "us-east-1"
VPC_ID = "vpc-0123456789abcdef0"
SG_ID = "sg-0123456789abcdef0" # security group for the VPC association
ALIAS_ARN = "arn:aws:lambda:us-east-1:111122223333:function:orders-backend:live"
lat = boto3.client("vpc-lattice", region_name=REGION)
sn = lat.create_service_network(name="demo-net", authType="AWS_IAM")
tg = lat.create_target_group(
name="orders-tg", type="LAMBDA",
config={"lambdaEventStructureVersion": "V2"},
)
lat.register_targets(
targetGroupIdentifier=tg["id"], targets=[{"id": ALIAS_ARN}]
)
svc = lat.create_service(name="orders", authType="AWS_IAM")
lat.create_listener(
serviceIdentifier=svc["id"], name="https-443",
protocol="HTTPS", port=443,
defaultAction={"forward": {"targetGroups": [
{"targetGroupIdentifier": tg["id"], "weight": 100}]}},
)
print(svc["id"], svc["arn"], svc["dnsEntry"]["domainName"])
The parameter names above match the boto3 references for create_service, create_target_group and create_listener. Two details matter. For LAMBDA target groups, port, protocol, VPC and health check settings are unsupported, so the only config is the event structure version (default V1, so set V2 explicitly). And an HTTPS listener without a custom domain still works on the generated service name; the certificate question only arises when you bring your own domain.
If you have not seen register_targets before, note it takes targets=[{"id": ...}] and accepts the Lambda alias ARN as the id. Creation is asynchronous, so poll get_service and get_target_group until status is ACTIVE before the next step. A target group or service stuck in CREATE_FAILED must be deleted and recreated.
Step 3: associate the service and the VPC
import time
lat.create_service_network_service_association(
serviceIdentifier=svc["id"],
serviceNetworkIdentifier=sn["id"],
)
assoc = lat.create_service_network_vpc_association(
serviceNetworkIdentifier=sn["id"],
vpcIdentifier=VPC_ID,
securityGroupIds=[SG_ID],
privateDnsEnabled=True,
)
while lat.get_service_network_vpc_association(
serviceNetworkVpcAssociationIdentifier=assoc["id"])["status"] != "ACTIVE":
time.sleep(5)
The VPC association accepts up to five security groups, and once you attach any, it must always keep at least one (to remove them all you delete and recreate the association). The security group's outbound rules govern what clients in that VPC may send into the network, so allow 443 outbound to the Lattice data plane. The docs point to a managed prefix list for these rules. Look up its current name and ID in your Region with aws ec2 describe-managed-prefix-lists rather than copying an address range from a blog post, including this one.
Step 4: the auth policy
Now the part that earns the product its keep. Allow vpc-lattice-svcs:Invoke only when the call is authenticated and originates from our VPC.
policy = {
"Version": "2012-10-17",
"Statement": [{
"Effect": "Allow",
"Principal": "*",
"Action": "vpc-lattice-svcs:Invoke",
"Resource": f"{svc['arn']}/*",
"Condition": {
"StringNotEquals": {"aws:PrincipalType": "Anonymous"},
"StringEquals": {"vpc-lattice-svcs:SourceVpc": VPC_ID},
},
}],
}
lat.put_auth_policy(
resourceIdentifier=svc["id"], policy=json.dumps(policy)
)
lat.put_auth_policy(
resourceIdentifier=sn["id"],
policy=json.dumps({"Version": "2012-10-17", "Statement": [{
"Effect": "Allow", "Principal": "*",
"Action": "vpc-lattice-svcs:Invoke", "Resource": "*",
"Condition": {"StringNotEquals": {"aws:PrincipalType": "Anonymous"}},
}]}),
)
The first policy is modeled on the example in the auth policies docs. The second exists because we set the network's auth type to AWS_IAM, and by the documented rule that needs its own explicit allow or everything is denied. In a real organization the network policy is where you would put aws:PrincipalOrgID and leave per-service rules to service owners.
Step 5: a signed client inside the VPC
The client is a Lambda attached to the VPC and the same security group. Unsigned requests fail, and the docs say payload signing is unsupported: you send x-amz-content-sha256: UNSIGNED-PAYLOAD and disable payload signing in the signer.
# orders_client.py (env var ORDERS_URL = https://<dnsEntry domainName>)
import os, json, urllib.request
import botocore.session
from botocore.auth import SigV4Auth
from botocore.awsrequest import AWSRequest
def handler(event, context):
url = os.environ["ORDERS_URL"] + "/orders/42"
region = os.environ["AWS_REGION"]
creds = botocore.session.Session().get_credentials().get_frozen_credentials()
req = AWSRequest(method="GET", url=url,
headers={"x-amz-content-sha256": "UNSIGNED-PAYLOAD"})
req.context["payload_signing_enabled"] = False
SigV4Auth(creds, "vpc-lattice-svcs", region).add_auth(req)
r = urllib.request.Request(url, headers=dict(req.headers.items()))
with urllib.request.urlopen(r, timeout=10) as resp:
return {"status": resp.status, "body": json.loads(resp.read())}
The AWS example uses the CRT signer classes (CrtSigV4Auth); the standard SigV4Auth signs identically for SigV4, and SigV4A is the case that needs awscrt. If your runtime lacks a recent botocore, switch to the documented CRT variant.
Verify it works
Invoke the client: aws lambda invoke --function-name orders-client out.json && cat out.json. A working build returns something shaped like this:
{"status": 200, "body": {"service": "orders", "path": "/orders/42",
"method": "GET", "caller_principal": "arn:aws:sts::111122223333:assumed-role/orders-client-role/orders-client",
"caller_vpc": "arn:aws:ec2:us-east-1:111122223333:vpc/vpc-0123456789abcdef0"}}
The proof is in the last two fields. The backend never authenticated anyone: Lattice did, and handed it the verified principal and the source VPC. Then run the negative test. Change the policy's SourceVpc to a wrong VPC ID, wait a few seconds, invoke again, and expect an HTTP 403. Change it back. In CloudWatch, AWS/VpcLattice publishes HTTPCode_VpcLattice_403_Count per service, which is how you will notice policy denials in production.
When it breaks
The name does not resolve. The client VPC is not associated, the association is not yet ACTIVE, or private DNS is disabled on it. Resolve from inside the VPC, never from your laptop.
You get a 403 with a correct policy. Check the evaluation chain: the caller's IAM policy needs vpc-lattice-svcs:Invoke, the network policy needs an allow, and the service policy needs an allow. An explicit deny in any of them wins. A network with auth type AWS_IAM and no policy denies everything.
Unsigned or badly signed requests fail. Confirm the signing name is exactly vpc-lattice-svcs, the region matches, and the UNSIGNED-PAYLOAD header is present with payload signing disabled.
502 or 504 from the Lambda target. Deregistering a function mid-flight causes 5XX for in-flight requests, and a function that times out or returns a malformed response shape (missing statusCode) surfaces as a gateway error. To swap functions safely, create a new target group and move the listener's weights.
Connections drop after a minute of silence. The idle timeout default is 60 seconds. Raise idleTimeoutSeconds on the service up to 600, and remember the 10 minute connection cap.
Cleanup
Order matters because of dependencies. Delete the VPC association and the service association first, then the listener, service, target group (deregister targets first), then the service network. Delete both Lambdas, their roles and the security group. A final check: aws vpc-lattice list-services and list-service-networks should return empty lists. Since billing is per service-hour, a service left running costs about $18 a month, which is the reason to confirm.
for a in lat.list_service_network_vpc_associations(vpcIdentifier=VPC_ID)["items"]:
lat.delete_service_network_vpc_association(
serviceNetworkVpcAssociationIdentifier=a["id"])
for a in lat.list_service_network_service_associations(
serviceIdentifier=svc["id"])["items"]:
lat.delete_service_network_service_association(
serviceNetworkServiceAssociationIdentifier=a["id"])
Wait for the associations to disappear, then call delete_service and delete_service_network, and finally delete_target_group.

