Add dual-stack IPv6 VPC with egress optimization and VPC endpoints

- 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>
This commit is contained in:
2026-02-14 17:01:13 -08:00
parent 21f4fef6a3
commit 2e02b02023
4 changed files with 1042 additions and 2 deletions

View File

@@ -0,0 +1,649 @@
# 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.