- Add Amazon-provided IPv6 /56 CIDR block with auto-carved /64 per subnet - Add Egress-Only Internet Gateway for free IPv6 outbound from private subnets - Add IPv6 routes: public subnets via IGW, private subnets via EOIGW - Add IPv6 NACL entries for subnet tier 2 - Add DynamoDB gateway endpoint (free, alongside existing S3) - Add 6 interface endpoints: ECR, ECR Docker, CloudWatch Logs, STS, Secrets Manager, SSM with shared security group - Add enableIpv6 prop (default true) and interfaceEndpoints config - Update VPC stack with context params for all new features - Include design doc and implementation plan Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
22 KiB
Dual-Stack VPC with Egress Optimization — Implementation Plan
For Claude: REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task.
Goal: Add IPv6 dual-stack support and VPC endpoints to the SpicyVpc construct to eliminate most NAT Gateway egress costs.
Architecture: The VPC gets an Amazon-provided IPv6 /56 CIDR. All subnets become dual-stack (/64 each). Public subnets route IPv6 via the existing IGW. Private subnets route IPv6 via a new Egress-Only IGW (free, outbound-only). Interface VPC endpoints handle AWS service traffic without NAT. NAT Gateways remain as IPv4 fallback.
Tech Stack: AWS CDK (TypeScript), L1 CfnResources (to match existing construct pattern), L2 InterfaceVpcEndpoint for interface endpoints.
Design doc: docs/plans/2026-02-14-dual-stack-vpc-egress-optimization-design.md
Task 1: Add new props and interface endpoint config type
Files:
- Modify:
resources/lib/constructs/spicy-vpc.ts:30-107(SpicyVpcProps interface)
Step 1: Add the VpcInterfaceEndpointConfig interface and new props
Add after the SpicyVpcTags interface (line 25) and before SpicyVpcProps:
/**
* Configuration for VPC Interface Endpoints
*/
export interface VpcInterfaceEndpointConfig {
/** ECR API endpoint (for image metadata) @default true */
readonly ecr?: boolean;
/** ECR Docker endpoint (for image layer pulls) @default true */
readonly ecrDocker?: boolean;
/** CloudWatch Logs endpoint @default true */
readonly cloudwatchLogs?: boolean;
/** STS endpoint (for IAM role assumption) @default true */
readonly sts?: boolean;
/** Secrets Manager endpoint @default true */
readonly secretsManager?: boolean;
/** SSM endpoint (for Parameter Store, Session Manager) @default true */
readonly ssm?: boolean;
}
Add to SpicyVpcProps interface before the closing brace:
/**
* Enable IPv6 dual-stack with Egress-Only Internet Gateway.
* Adds Amazon-provided IPv6 CIDR, assigns /64 to each subnet,
* and routes private subnet IPv6 egress through EOIGW (free, outbound-only).
* @default true
*/
readonly enableIpv6?: boolean;
/**
* Configuration for VPC Interface Endpoints.
* Only applies when createVpcEndpoints is true.
* Each endpoint costs ~$0.01/hr but eliminates NAT data processing costs for that service.
* @default All endpoints enabled (ECR, ECR Docker, CloudWatch Logs, STS, Secrets Manager, SSM)
*/
readonly interfaceEndpoints?: VpcInterfaceEndpointConfig;
Step 2: Verify TypeScript compiles
Run: cd /Volumes/Engineering/Kodeniks/spicy-automation/resources && pnpm exec tsc --noEmit
Expected: Clean compilation (new props are optional, no consumers break)
Step 3: Commit
git add resources/lib/constructs/spicy-vpc.ts
git commit -m "feat(vpc): add IPv6 and interface endpoint props to SpicyVpcProps"
Task 2: Add IPv6 CIDR block and Egress-Only Internet Gateway
Files:
- Modify:
resources/lib/constructs/spicy-vpc.ts— constructor body, after DHCP options (after line 225)
Step 1: Add IPv6 CIDR block and EOIGW after DHCP association
Insert after the VPCDHCPOptionsAssociation block (after line 225), before the public route table section:
// IPv6 Dual-Stack Configuration
const enableIpv6 = props.enableIpv6 ?? true;
let ipv6CidrBlock: ec2.CfnVPCCidrBlock | undefined;
let egressOnlyIgw: ec2.CfnEgressOnlyInternetGateway | undefined;
if (enableIpv6) {
// Add Amazon-provided IPv6 /56 CIDR block to the VPC
ipv6CidrBlock = new ec2.CfnVPCCidrBlock(this, 'IPv6CidrBlock', {
vpcId: cfnVpc.ref,
amazonProvidedIpv6CidrBlock: true,
});
((ipv6CidrBlock.node.defaultChild as cdk.CfnResource) ?? ipv6CidrBlock).overrideLogicalId('IPv6CidrBlock');
// Egress-Only Internet Gateway (outbound IPv6 only, drops unsolicited inbound)
egressOnlyIgw = new ec2.CfnEgressOnlyInternetGateway(this, 'EgressOnlyInternetGateway', {
vpcId: cfnVpc.ref,
});
((egressOnlyIgw.node.defaultChild as cdk.CfnResource) ?? egressOnlyIgw).overrideLogicalId(
'EgressOnlyInternetGateway'
);
}
Step 2: Verify TypeScript compiles
Run: cd /Volumes/Engineering/Kodeniks/spicy-automation/resources && pnpm exec tsc --noEmit
Expected: Clean (variables declared but not yet used — that's fine, used in Task 3)
Step 3: Commit
git add resources/lib/constructs/spicy-vpc.ts
git commit -m "feat(vpc): add IPv6 CIDR block and Egress-Only Internet Gateway"
Task 3: Add IPv6 CIDRs to subnets and IPv6 routes
Files:
- Modify:
resources/lib/constructs/spicy-vpc.ts— inside theazLetters.forEachloop
This is the most complex task. We need to:
- Add IPv6 CIDR to each subnet (public, private1, private2)
- Add
::/0route to public route table via IGW - Add
::/0routes to private route tables via EOIGW
Step 1: Add IPv6 route on public route table
After the existing PublicSubnetRoute (line 244), add:
// Public subnet IPv6 route via IGW
if (enableIpv6) {
const publicIpv6Route = new ec2.CfnRoute(this, 'PublicSubnetIpv6Route', {
routeTableId: publicRouteTable.ref,
destinationIpv6CidrBlock: '::/0',
gatewayId: igw.ref,
});
((publicIpv6Route.node.defaultChild as cdk.CfnResource) ?? publicIpv6Route).overrideLogicalId(
'PublicSubnetIpv6Route'
);
}
Step 2: Add IPv6 CIDR to public subnets
Inside the azLetters.forEach loop, after the public subnet CfnSubnet is created (after line 274 — the overrideLogicalId call), add:
// Assign IPv6 /64 to public subnet
if (enableIpv6 && ipv6CidrBlock) {
const publicIpv6Cidr = cdk.Fn.select(
index,
cdk.Fn.cidr(
cdk.Fn.select(0, cfnVpc.attrIpv6CidrBlocks),
256,
'64'
)
);
publicSubnet.ipv6CidrBlock = publicIpv6Cidr;
publicSubnet.assignIpv6AddressOnCreation = true;
publicSubnet.addDependency(ipv6CidrBlock);
}
Step 3: Add IPv6 CIDR to private subnet 1 and EOIGW route
Inside the if (createPrivateSubnets) block, after private subnet 1 is created and its route table association (after line 370), add:
// Assign IPv6 /64 to private subnet 1 (offset by numberOfAzs to avoid public subnet range)
if (enableIpv6 && ipv6CidrBlock && egressOnlyIgw) {
const private1Ipv6Cidr = cdk.Fn.select(
numberOfAzs + index,
cdk.Fn.cidr(
cdk.Fn.select(0, cfnVpc.attrIpv6CidrBlocks),
256,
'64'
)
);
privateSubnet1.ipv6CidrBlock = private1Ipv6Cidr;
privateSubnet1.assignIpv6AddressOnCreation = true;
privateSubnet1.addDependency(ipv6CidrBlock);
// IPv6 egress route via Egress-Only IGW
const private1Ipv6Route = new ec2.CfnRoute(this, `PrivateSubnet${azUpper}1Ipv6Route`, {
routeTableId: privateRouteTable1.ref,
destinationIpv6CidrBlock: '::/0',
egressOnlyInternetGatewayId: egressOnlyIgw.ref,
});
((private1Ipv6Route.node.defaultChild as cdk.CfnResource) ?? private1Ipv6Route).overrideLogicalId(
`PrivateSubnet${azUpper}1Ipv6Route`
);
}
Step 4: Add IPv6 CIDR to private subnet 2 and EOIGW route
Inside the if (createAdditionalPrivateSubnets && cidrs.private2Cidr) block, after private subnet 2 route table association (after line 436), add (before the NACL section):
// Assign IPv6 /64 to private subnet 2 (offset by 2*numberOfAzs)
if (enableIpv6 && ipv6CidrBlock && egressOnlyIgw) {
const private2Ipv6Cidr = cdk.Fn.select(
2 * numberOfAzs + index,
cdk.Fn.cidr(
cdk.Fn.select(0, cfnVpc.attrIpv6CidrBlocks),
256,
'64'
)
);
privateSubnet2.ipv6CidrBlock = private2Ipv6Cidr;
privateSubnet2.assignIpv6AddressOnCreation = true;
privateSubnet2.addDependency(ipv6CidrBlock);
// IPv6 egress route via Egress-Only IGW
const private2Ipv6Route = new ec2.CfnRoute(this, `PrivateSubnet${azUpper}2Ipv6Route`, {
routeTableId: privateRouteTable2.ref,
destinationIpv6CidrBlock: '::/0',
egressOnlyInternetGatewayId: egressOnlyIgw.ref,
});
((private2Ipv6Route.node.defaultChild as cdk.CfnResource) ?? private2Ipv6Route).overrideLogicalId(
`PrivateSubnet${azUpper}2Ipv6Route`
);
}
Step 5: Update NACL rules to include IPv6
In the NACL section for private subnet 2, after the existing outbound rule (after line 472), add IPv6 NACL entries:
// Allow all inbound IPv6
if (enableIpv6) {
const naclInboundIpv6 = new ec2.CfnNetworkAclEntry(
this,
`PrivateSubnet${azUpper}2NetworkACLEntryInboundIpv6`,
{
networkAclId: nacl.ref,
ruleNumber: 101,
protocol: -1,
ruleAction: 'allow',
egress: false,
ipv6CidrBlock: '::/0',
}
);
((naclInboundIpv6.node.defaultChild as cdk.CfnResource) ?? naclInboundIpv6).overrideLogicalId(
`PrivateSubnet${azUpper}2NetworkACLEntryInboundIpv6`
);
// Allow all outbound IPv6
const naclOutboundIpv6 = new ec2.CfnNetworkAclEntry(
this,
`PrivateSubnet${azUpper}2NetworkACLEntryOutboundIpv6`,
{
networkAclId: nacl.ref,
ruleNumber: 101,
protocol: -1,
ruleAction: 'allow',
egress: true,
ipv6CidrBlock: '::/0',
}
);
((naclOutboundIpv6.node.defaultChild as cdk.CfnResource) ?? naclOutboundIpv6).overrideLogicalId(
`PrivateSubnet${azUpper}2NetworkACLEntryOutboundIpv6`
);
}
Step 6: Verify TypeScript compiles and synth
Run:
cd /Volumes/Engineering/Kodeniks/spicy-automation/resources
pnpm exec tsc --noEmit
pnpm exec cdk synth -c stackType=vpc -c stackName=test-vpc -c ownerTag=T -c productTag=t -c componentTag=c 2>&1 | grep -E '(IPv6|EgressOnly|Ipv6)'
Expected: Clean compilation. Synth output should show IPv6CidrBlock, EgressOnlyInternetGateway, and Ipv6Route resources.
Step 7: Commit
git add resources/lib/constructs/spicy-vpc.ts
git commit -m "feat(vpc): add IPv6 CIDRs to all subnets and EOIGW routes for private subnets"
Task 4: Add DynamoDB Gateway Endpoint
Files:
- Modify:
resources/lib/constructs/spicy-vpc.ts— VPC endpoints section (after line 513)
Step 1: Add DynamoDB gateway endpoint
After the existing S3 endpoint block (after line 531), add:
// DynamoDB VPC Endpoint (Gateway type - free)
const dynamoDbEndpoint = new ec2.CfnVPCEndpoint(this, 'DynamoDBVPCEndpoint', {
vpcId: cfnVpc.ref,
serviceName: `com.amazonaws.${cdk.Stack.of(this).region}.dynamodb`,
routeTableIds: privateRouteTables.map((rt) => rt.ref),
policyDocument: {
Version: '2012-10-17',
Statement: [
{
Action: '*',
Effect: 'Allow',
Resource: '*',
Principal: '*',
},
],
},
});
((dynamoDbEndpoint.node.defaultChild as cdk.CfnResource) ?? dynamoDbEndpoint).overrideLogicalId(
'DynamoDBVPCEndpoint'
);
Step 2: Verify TypeScript compiles
Run: cd /Volumes/Engineering/Kodeniks/spicy-automation/resources && pnpm exec tsc --noEmit
Step 3: Commit
git add resources/lib/constructs/spicy-vpc.ts
git commit -m "feat(vpc): add DynamoDB gateway VPC endpoint"
Task 5: Add Interface VPC Endpoints
Files:
- Modify:
resources/lib/constructs/spicy-vpc.ts— after gateway endpoints section
Interface endpoints need:
- A security group allowing HTTPS from the VPC CIDR
- Private DNS enabled
- Subnets (one per AZ in private subnet 1)
Since we have an imported this.vpc (L2) and the private subnets as ISubnet references, we can use the L2 InterfaceVpcEndpoint construct here.
Step 1: Add interface endpoints section
After the DynamoDB endpoint (end of gateway endpoints section), add:
// Interface VPC Endpoints (cost ~$0.01/hr each, but eliminate NAT data processing)
const endpointConfig = props.interfaceEndpoints ?? {};
// Security group for all interface endpoints
const endpointSecurityGroup = new ec2.CfnSecurityGroup(this, 'VpcEndpointSecurityGroup', {
vpcId: cfnVpc.ref,
groupDescription: 'Security group for VPC Interface Endpoints',
securityGroupIngress: [
{
ipProtocol: 'tcp',
fromPort: 443,
toPort: 443,
cidrIp: vpcCidr,
description: 'Allow HTTPS from VPC IPv4',
},
...(enableIpv6
? [
{
ipProtocol: 'tcp',
fromPort: 443,
toPort: 443,
cidrIpv6: '::/0',
description: 'Allow HTTPS from VPC IPv6',
},
]
: []),
],
});
((endpointSecurityGroup.node.defaultChild as cdk.CfnResource) ?? endpointSecurityGroup).overrideLogicalId(
'VpcEndpointSecurityGroup'
);
const privateSubnetIds = Array.from(this.privateSubnets1.values()).map((s) => s.subnetId);
const region = cdk.Stack.of(this).region;
const interfaceEndpointServices: { id: string; service: string; enabled: boolean }[] = [
{ id: 'ECRApiEndpoint', service: `com.amazonaws.${region}.ecr.api`, enabled: endpointConfig.ecr ?? true },
{
id: 'ECRDockerEndpoint',
service: `com.amazonaws.${region}.ecr.dkr`,
enabled: endpointConfig.ecrDocker ?? true,
},
{
id: 'CloudWatchLogsEndpoint',
service: `com.amazonaws.${region}.logs`,
enabled: endpointConfig.cloudwatchLogs ?? true,
},
{ id: 'STSEndpoint', service: `com.amazonaws.${region}.sts`, enabled: endpointConfig.sts ?? true },
{
id: 'SecretsManagerEndpoint',
service: `com.amazonaws.${region}.secretsmanager`,
enabled: endpointConfig.secretsManager ?? true,
},
{ id: 'SSMEndpoint', service: `com.amazonaws.${region}.ssm`, enabled: endpointConfig.ssm ?? true },
];
for (const ep of interfaceEndpointServices) {
if (!ep.enabled) continue;
const endpoint = new ec2.CfnVPCEndpoint(this, ep.id, {
vpcId: cfnVpc.ref,
serviceName: ep.service,
vpcEndpointType: 'Interface',
privateDnsEnabled: true,
subnetIds: privateSubnetIds,
securityGroupIds: [endpointSecurityGroup.ref],
});
((endpoint.node.defaultChild as cdk.CfnResource) ?? endpoint).overrideLogicalId(ep.id);
}
Step 2: Verify TypeScript compiles and synth
Run:
cd /Volumes/Engineering/Kodeniks/spicy-automation/resources
pnpm exec tsc --noEmit
pnpm exec cdk synth -c stackType=vpc -c stackName=test-vpc -c ownerTag=T -c productTag=t -c componentTag=c 2>&1 | grep -c 'VPCEndpoint'
Expected: Clean compilation. Grep should show 8 VPCEndpoint resources (S3 + DynamoDB gateways + 6 interface).
Step 3: Commit
git add resources/lib/constructs/spicy-vpc.ts
git commit -m "feat(vpc): add interface VPC endpoints for ECR, CloudWatch, STS, SecretsManager, SSM"
Task 6: Add new CloudFormation outputs and expose EOIGW
Files:
- Modify:
resources/lib/constructs/spicy-vpc.ts— class properties (line ~159) andaddOutputsmethod
Step 1: Add new public properties
After the s3Endpoint property (line 159), add:
/** Egress-Only Internet Gateway (IPv6 outbound from private subnets) */
public readonly egressOnlyInternetGateway?: ec2.CfnEgressOnlyInternetGateway;
Step 2: Assign EOIGW to the property
In the constructor, where egressOnlyIgw is created (Task 2), after creating the EOIGW, assign it:
this.egressOnlyInternetGateway = egressOnlyIgw;
Step 3: Update addOutputs signature and add IPv6 outputs
Update the addOutputs method to accept the new parameters. Add enableIpv6 and cfnVpc to the method signature:
Change the method signature to:
private addOutputs(
cfnVpc: ec2.CfnVPC,
azLetters: string[],
publicRouteTable: ec2.CfnRouteTable,
privateRouteTables: ec2.CfnRouteTable[],
createPrivateSubnets: boolean,
createAdditionalPrivateSubnets: boolean,
enableIpv6: boolean
): void {
At the end of the addOutputs method, before the closing brace, add:
// IPv6 outputs
if (enableIpv6 && this.egressOnlyInternetGateway) {
const eoigwOutput = new cdk.CfnOutput(this, 'EgressOnlyInternetGatewayId', {
value: this.egressOnlyInternetGateway.ref,
description: 'Egress-Only Internet Gateway ID',
exportName: `${stack.stackName}-EgressOnlyIGW`,
});
eoigwOutput.overrideLogicalId('EgressOnlyInternetGatewayId');
}
Step 4: Update the addOutputs call site
Update the addOutputs call in the constructor to pass the new parameter:
this.addOutputs(
cfnVpc,
azLetters,
publicRouteTable,
privateRouteTables,
createPrivateSubnets,
createAdditionalPrivateSubnets,
enableIpv6
);
Step 5: Verify TypeScript compiles and synth
Run:
cd /Volumes/Engineering/Kodeniks/spicy-automation/resources
pnpm exec tsc --noEmit
pnpm exec cdk synth -c stackType=vpc -c stackName=test-vpc -c ownerTag=T -c productTag=t -c componentTag=c 2>&1 | grep 'EgressOnly'
Expected: Clean compilation. Output should show the EOIGW output.
Step 6: Commit
git add resources/lib/constructs/spicy-vpc.ts
git commit -m "feat(vpc): add EOIGW property and IPv6 CloudFormation outputs"
Task 7: Update VPC stack to pass new context params
Files:
- Modify:
resources/lib/stacks/spicy-vpc-stack.ts—fromContextmethod
Step 1: Add context parsing for new props
In the fromContext method, after the existing context parsing (around line 88), add:
const enableIpv6 = app.node.tryGetContext('enableIpv6') as string | undefined;
// Interface endpoint overrides
const disableEcrEndpoint = app.node.tryGetContext('disableEcrEndpoint') as string | undefined;
const disableEcrDockerEndpoint = app.node.tryGetContext('disableEcrDockerEndpoint') as string | undefined;
const disableCloudwatchLogsEndpoint = app.node.tryGetContext('disableCloudwatchLogsEndpoint') as string | undefined;
const disableStsEndpoint = app.node.tryGetContext('disableStsEndpoint') as string | undefined;
const disableSecretsManagerEndpoint = app.node.tryGetContext('disableSecretsManagerEndpoint') as string | undefined;
const disableSsmEndpoint = app.node.tryGetContext('disableSsmEndpoint') as string | undefined;
In the vpcConfigBuilder type, add:
enableIpv6?: boolean;
interfaceEndpoints?: SpicyVpcProps['interfaceEndpoints'];
After the existing config assignments (around line 124), add:
if (enableIpv6 !== undefined) {
vpcConfigBuilder.enableIpv6 = enableIpv6 !== 'false';
}
// Build interface endpoint config if any overrides provided
if (
disableEcrEndpoint ||
disableEcrDockerEndpoint ||
disableCloudwatchLogsEndpoint ||
disableStsEndpoint ||
disableSecretsManagerEndpoint ||
disableSsmEndpoint
) {
vpcConfigBuilder.interfaceEndpoints = {
ecr: disableEcrEndpoint !== 'true',
ecrDocker: disableEcrDockerEndpoint !== 'true',
cloudwatchLogs: disableCloudwatchLogsEndpoint !== 'true',
sts: disableStsEndpoint !== 'true',
secretsManager: disableSecretsManagerEndpoint !== 'true',
ssm: disableSsmEndpoint !== 'true',
};
}
Step 2: Add the SpicyVpcProps import if not already imported
The file already imports SpicyVpc and SpicyVpcProps on line 3 — verify this covers the new VpcInterfaceEndpointConfig type (it doesn't need to be imported separately since it's referenced via SpicyVpcProps['interfaceEndpoints']).
Step 3: Verify TypeScript compiles
Run: cd /Volumes/Engineering/Kodeniks/spicy-automation/resources && pnpm exec tsc --noEmit
Step 4: Commit
git add resources/lib/stacks/spicy-vpc-stack.ts
git commit -m "feat(vpc): add IPv6 and endpoint context params to VPC stack"
Task 8: Full synth verification and final commit
Step 1: Run full type check
Run: cd /Volumes/Engineering/Kodeniks/spicy-automation/resources && pnpm exec tsc --noEmit
Expected: Clean
Step 2: Synth VPC stack and verify all resources
Run:
cd /Volumes/Engineering/Kodeniks/spicy-automation/resources
pnpm exec cdk synth -c stackType=vpc -c stackName=test-vpc -c ownerTag=T -c productTag=t -c componentTag=c 2>&1 | grep -E 'Type.*AWS::'
Expected: Should include:
AWS::EC2::VPCCidrBlock(IPv6CidrBlock)AWS::EC2::EgressOnlyInternetGatewayAWS::EC2::Routeentries for IPv6 (::/0)AWS::EC2::VPCEndpoint(8 total: S3, DynamoDB, ECR, ECR Docker, Logs, STS, SecretsManager, SSM)AWS::EC2::SecurityGroupfor endpoints
Step 3: Synth VPC with IPv6 disabled (regression check)
Run:
pnpm exec cdk synth -c stackType=vpc -c stackName=test-vpc -c ownerTag=T -c productTag=t -c componentTag=c -c enableIpv6=false 2>&1 | grep -c 'IPv6\|EgressOnly'
Expected: 0 matches (no IPv6 resources when disabled)
Step 4: Synth ECS cluster stack to verify no regressions
Run:
pnpm exec cdk synth -c stackType=ecs-cluster -c stackName=test -c vpcStackName=test-vpc -c ownerTag=T -c productTag=t -c componentTag=c -c environment=d -c numberOfAzs=2 2>&1 | tail -5
Expected: Clean synth, no errors
Step 5: Verify all changes are clean
Run: git diff --stat
Review that only spicy-vpc.ts and spicy-vpc-stack.ts were modified.