Skip to content

Feature request: first-class VPC support on BlocksStack #203

Description

@svidgen

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:

  1. Creating a VPC manually after BlocksStack.create()
  2. Reaching into the handler via CfnFunction.vpcConfig (L1 escape hatch)
  3. Manually granting AWSLambdaVPCAccessExecutionRole
  4. Manually provisioning VPC Endpoints for every AWS service used by building blocks
  5. 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

  1. Lambda placed in VPC at L2 construction timeisBoundToVpc is true, connections is usable, VPC execution policy is auto-granted.
  2. 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.
  3. 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.
  4. Security groups auto-wired — the framework knows Lambda needs to reach Aurora on 5432, so it creates the ingress rule automatically.
  5. 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.

Metadata

Metadata

Assignees

No one assigned

    Labels

    feature-requestNew feature or enhancement requestpending-maintainer-responseWaiting on a response from the maintainerspending-triageIssue is pending triage by the maintainers

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions