Summary
Add a vpc option to BlocksStack.create() (and BlocksBackend.create()) so that the Lambda handler and all VPC-resident building blocks share a single VPC without requiring L1 escape hatches.
Motivation
Today, placing a Blocks app inside a VPC requires:
- Creating a VPC manually after
BlocksStack.create()
- Reaching into the handler via
CfnFunction.vpcConfig (L1 escape hatch)
- Manually granting
AWSLambdaVPCAccessExecutionRole
- Manually provisioning VPC Endpoints for every AWS service used by building blocks
- Manually wiring security groups between resources
Worse, building blocks that provision their own VPC-resident resources (e.g., bb-data creates its own VPC for Aurora) end up in separate, isolated VPCs that cannot communicate with the handler or each other without VPC peering.
Proposed API
Option 1: Stack-level vpc prop (simplest, covers 80% case)
import * as ec2 from 'aws-cdk-lib/aws-ec2';
// Option A: Let Blocks create and manage the VPC
export const blocksStack = await BlocksStack.create(app, stackName, {
backendHandlerPath: join(__dirname, 'index.handler.ts'),
backendCDKPath: join(__dirname, 'index.ts'),
vpc: {
create: { maxAzs: 2, natGateways: 1 },
lambdaSubnets: { subnetType: ec2.SubnetType.PRIVATE_WITH_EGRESS },
},
});
// Option B: Bring your own VPC (from same stack, another stack, or imported by ID)
const existingVpc = ec2.Vpc.fromLookup(app, 'Vpc', { vpcId: 'vpc-abc123' });
export const blocksStack = await BlocksStack.create(app, stackName, {
backendHandlerPath: join(__dirname, 'index.handler.ts'),
backendCDKPath: join(__dirname, 'index.ts'),
vpc: {
existing: existingVpc,
lambdaSubnets: { subnetType: ec2.SubnetType.PRIVATE_WITH_EGRESS },
},
});
Option 2: VPC as a Building Block (per-handler granularity)
If/when handlers can specify their own compute targets, VPC placement becomes a per-handler decision rather than a stack-wide one. A VpcNetwork building block would let handlers opt in individually:
import { VpcNetwork, ApiNamespace, Database } from '@aws-blocks/blocks';
// VPC as a shared resource — created or imported
const vpc = new VpcNetwork(scope, 'network', {
maxAzs: 2,
natGateways: 1,
subnets: {
public: { maxAzs: 2 },
private: { maxAzs: 2 },
isolated: { maxAzs: 2 },
},
});
// Or bring an existing VPC, mapping its subnets to roles:
const vpc = new VpcNetwork(scope, 'network', {
fromExisting: 'vpc-abc123',
subnets: {
private: { name: 'app-tier' },
isolated: { name: 'database-tier' },
},
});
// Handlers opt into VPC individually
export const adminApi = new ApiNamespace(scope, 'admin', handler, { vpc });
export const publicApi = new ApiNamespace(scope, 'public', handler); // no VPC — stays outside
// Building blocks that need VPC reference it explicitly
const db = new Database(scope, 'main', { vpc });
Design principles for the building block approach:
- Building blocks know their default subnet role:
ApiNamespace defaults to private, Database defaults to isolated
- Subnet selection is a property of the VPC block, not of each consumer — consumers just say "put me in VPC" and the VPC knows where each role belongs
- For
fromExisting, the subnets config is a mapping ("your 'app-tier' subnets are what Blocks calls 'private'") — Blocks never creates or modifies subnets in a VPC it doesn't own
- Auto-detect works for VPCs created by CDK (subnets are tagged with type metadata); explicit mapping needed for custom-named subnets
Why this matters for per-handler compute:
- A handler that only uses DynamoDB + external APIs can stay outside the VPC (no NAT cost, no endpoint cost)
- A handler that needs Aurora goes in the VPC
- Blocks auto-provisions VPC Endpoints only for the services that VPC-placed handlers actually use
- Security groups auto-wired between VPC-placed handlers and VPC-placed data blocks
Expected behavior when vpc is provided
- Lambda placed in VPC at L2 construction time —
isBoundToVpc is true, connections is usable, VPC execution policy is auto-granted.
- VPC propagated to child building blocks — when
bb-data (or future VPC-resident blocks) detects its parent scope has a VPC, it uses that VPC instead of creating its own. Aurora, Lambda, and any other VPC-resident resources land in the same network.
- VPC Endpoints auto-provisioned — based on which building blocks are in the stack (or per-handler, in the BB approach). If
DistributedTable is present → DynamoDB gateway endpoint. If Database is present → Secrets Manager interface endpoint. Etc.
- Security groups auto-wired — the framework knows Lambda needs to reach Aurora on 5432, so it creates the ingress rule automatically.
blocksStack.vpc getter exposed — so customers can reference the shared VPC for their own constructs.
Why Blocks should accept (not create) the VPC by default
Customers may have:
- A shared "network stack" managed by a platform team
- An existing VPC imported via
Vpc.fromLookup() or Vpc.fromVpcAttributes()
- Custom routing (Transit Gateway, VPN, Direct Connect)
- Organizational CIDR allocation policies
Blocks should accept an ec2.IVpc and use it — the same way CDK's own Function({ vpc }) works. If customers don't have an existing VPC, the create option is a convenience shorthand. In the BB approach, fromExisting provides the same flexibility with a Blocks-native interface.
What the escape-hatch workaround looks like today
// After BlocksStack.create()...
const vpc = new ec2.Vpc(blocksStack, 'AppVpc', { /* ... */ });
const lambdaSg = new ec2.SecurityGroup(blocksStack, 'LambdaSg', { vpc });
// L1 escape hatch — fragile, bypasses L2 abstractions
const cfnFunction = blocksStack.handler.node.defaultChild as lambda.CfnFunction;
cfnFunction.vpcConfig = {
subnetIds: vpc.selectSubnets({ subnetType: ec2.SubnetType.PRIVATE_WITH_EGRESS }).subnetIds,
securityGroupIds: [lambdaSg.securityGroupId],
};
// Manual policy grant
blocksStack.handler.role!.addManagedPolicy(
iam.ManagedPolicy.fromManagedPolicyArn(blocksStack, 'VpcPolicy',
'arn:aws:iam::aws:policy/service-role/AWSLambdaVPCAccessExecutionRole')
);
// Manual VPC endpoints for each service...
vpc.addGatewayEndpoint('DynamoDbEndpoint', { service: ec2.GatewayVpcEndpointAwsService.DYNAMODB });
vpc.addGatewayEndpoint('S3Endpoint', { service: ec2.GatewayVpcEndpointAwsService.S3 });
vpc.addInterfaceEndpoint('ApiGwEndpoint', { service: ec2.InterfaceVpcEndpointAwsService.APIGATEWAY });
Problems with this approach:
- L2
Function doesn't know it's in a VPC (isBoundToVpc false, connections unset)
bb-data still creates its own isolated VPC — resources can't reach each other
- Must manually discover which VPC Endpoints each block needs
- Security group wiring is manual and error-prone
Additional context
- This is fundamentally the same pattern as CDK's
Function({ vpc, vpcSubnets }) — just lifted to the BlocksStack level and propagated to children.
- The
Mixins pattern already exists in Blocks (e.g., SandboxDisableDeletionProtection). A VpcPlacement mixin could be an interim step, but proper L2 integration is the right long-term answer.
- Related:
bb-data already creates a VPC internally (materialize() in infra.ts). The refactor would be: if parent scope has a VPC, use it; otherwise create one (current behavior, for standalone use).
- The BB approach becomes especially relevant once per-handler compute targets land — at that point, VPC is no longer all-or-nothing for the stack.
- See the example-vpc POC for a working escape-hatch implementation.
Summary
Add a
vpcoption toBlocksStack.create()(andBlocksBackend.create()) so that the Lambda handler and all VPC-resident building blocks share a single VPC without requiring L1 escape hatches.Motivation
Today, placing a Blocks app inside a VPC requires:
BlocksStack.create()CfnFunction.vpcConfig(L1 escape hatch)AWSLambdaVPCAccessExecutionRoleWorse, building blocks that provision their own VPC-resident resources (e.g.,
bb-datacreates its own VPC for Aurora) end up in separate, isolated VPCs that cannot communicate with the handler or each other without VPC peering.Proposed API
Option 1: Stack-level
vpcprop (simplest, covers 80% case)Option 2: VPC as a Building Block (per-handler granularity)
If/when handlers can specify their own compute targets, VPC placement becomes a per-handler decision rather than a stack-wide one. A
VpcNetworkbuilding block would let handlers opt in individually:Design principles for the building block approach:
ApiNamespacedefaults toprivate,Databasedefaults toisolatedfromExisting, thesubnetsconfig is a mapping ("your 'app-tier' subnets are what Blocks calls 'private'") — Blocks never creates or modifies subnets in a VPC it doesn't ownWhy this matters for per-handler compute:
Expected behavior when
vpcis providedisBoundToVpcis true,connectionsis usable, VPC execution policy is auto-granted.bb-data(or future VPC-resident blocks) detects its parent scope has a VPC, it uses that VPC instead of creating its own. Aurora, Lambda, and any other VPC-resident resources land in the same network.DistributedTableis present → DynamoDB gateway endpoint. IfDatabaseis present → Secrets Manager interface endpoint. Etc.blocksStack.vpcgetter exposed — so customers can reference the shared VPC for their own constructs.Why Blocks should accept (not create) the VPC by default
Customers may have:
Vpc.fromLookup()orVpc.fromVpcAttributes()Blocks should accept an
ec2.IVpcand use it — the same way CDK's ownFunction({ vpc })works. If customers don't have an existing VPC, thecreateoption is a convenience shorthand. In the BB approach,fromExistingprovides the same flexibility with a Blocks-native interface.What the escape-hatch workaround looks like today
Problems with this approach:
Functiondoesn't know it's in a VPC (isBoundToVpcfalse,connectionsunset)bb-datastill creates its own isolated VPC — resources can't reach each otherAdditional context
Function({ vpc, vpcSubnets })— just lifted to the BlocksStack level and propagated to children.Mixinspattern already exists in Blocks (e.g.,SandboxDisableDeletionProtection). AVpcPlacementmixin could be an interim step, but proper L2 integration is the right long-term answer.bb-dataalready creates a VPC internally (materialize()ininfra.ts). The refactor would be: if parent scope has a VPC, use it; otherwise create one (current behavior, for standalone use).