# 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`: ```typescript /** * 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: ```typescript /** * 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** ```bash 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: ```typescript // 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** ```bash 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 the `azLetters.forEach` loop This is the most complex task. We need to: 1. Add IPv6 CIDR to each subnet (public, private1, private2) 2. Add `::/0` route to public route table via IGW 3. Add `::/0` routes to private route tables via EOIGW **Step 1: Add IPv6 route on public route table** After the existing `PublicSubnetRoute` (line 244), add: ```typescript // 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: ```typescript // 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: ```typescript // 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): ```typescript // 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: ```typescript // 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: ```bash 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** ```bash 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: ```typescript // 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** ```bash 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: ```typescript // 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: ```bash 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** ```bash 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) and `addOutputs` method **Step 1: Add new public properties** After the `s3Endpoint` property (line 159), add: ```typescript /** 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: ```typescript 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: ```typescript 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: ```typescript // 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: ```typescript this.addOutputs( cfnVpc, azLetters, publicRouteTable, privateRouteTables, createPrivateSubnets, createAdditionalPrivateSubnets, enableIpv6 ); ``` **Step 5: Verify TypeScript compiles and synth** Run: ```bash 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** ```bash 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` — `fromContext` method **Step 1: Add context parsing for new props** In the `fromContext` method, after the existing context parsing (around line 88), add: ```typescript 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: ```typescript enableIpv6?: boolean; interfaceEndpoints?: SpicyVpcProps['interfaceEndpoints']; ``` After the existing config assignments (around line 124), add: ```typescript 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** ```bash 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: ```bash 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::EgressOnlyInternetGateway` - `AWS::EC2::Route` entries for IPv6 (`::/0`) - `AWS::EC2::VPCEndpoint` (8 total: S3, DynamoDB, ECR, ECR Docker, Logs, STS, SecretsManager, SSM) - `AWS::EC2::SecurityGroup` for endpoints **Step 3: Synth VPC with IPv6 disabled (regression check)** Run: ```bash 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: ```bash 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.