3.2 Set Up Network Fabric¶
The network module (modules/network.pkl) creates the full VPC networking layer for the cluster. You do not run this module independently — it is composed into main.pkl and deployed as part of the full formae apply. This section explains what the module does, how it works, and where to customise it.
Architecture¶
Internet
│
┌───┴───┐
│ IGW │
└───┬───┘
│
┌───┴──────────────────────────────────┐
│ Public Subnets (one per AZ) │
│ - NAT Gateways │
│ - Network Load Balancer │
│ - mapPublicIpOnLaunch = true │
└───┬──────────────────────────────────┘
│ (NAT)
┌───┴──────────────────────────────────┐
│ Private Subnets (one per AZ) │
│ - Control Plane EC2 instances │
│ - Worker EC2 instances │
│ - mapPublicIpOnLaunch = false │
└──────────────────────────────────────┘
Step 1: Configure Network Variables¶
Open vars.pkl (or your environment override in envs/<environment>.pkl) and set the network parameters.
VPC CIDR¶
The vpcCidr defines the overall address space for the VPC. All subnets must fall within this range:
Change this if 10.0.0.0/16 conflicts with your existing networks. For example, the demo environment uses 10.2.0.0/16:
Availability Zones and Subnets¶
You must define one public and one private subnet per availability zone. These three lists must have the same length:
/// Availability zones for resource distribution.
availabilityZones: List<String> = List("af-south-1a")
/// CIDR blocks for public subnets (one per AZ, hosts NAT gateways and NLB).
publicSubnetCidrs: List<String> = List("10.0.1.0/24")
/// CIDR blocks for private subnets (one per AZ, hosts Kubernetes nodes).
privateSubnetCidrs: List<String> = List("10.0.11.0/24")
Single AZ (demo / cost saving):
availabilityZones = List("af-south-1a")
publicSubnetCidrs = List("10.2.1.0/24")
privateSubnetCidrs = List("10.2.11.0/24")
Multi-AZ (production / HA):
availabilityZones = List("af-south-1a", "af-south-1b", "af-south-1c")
publicSubnetCidrs = List("10.0.1.0/24", "10.0.2.0/24", "10.0.3.0/24")
privateSubnetCidrs = List("10.0.11.0/24", "10.0.12.0/24", "10.0.13.0/24")
NAT Gateway Strategy¶
NAT gateways give private subnet nodes outbound internet access. Choose a strategy:
/// Whether to create NAT gateways for private subnet internet access.
enableNatGateway: Boolean = true
/// Use a single shared NAT gateway instead of one per AZ (saves cost).
singleNatGateway: Boolean = false
| Setting | Behaviour | Cost |
|---|---|---|
singleNatGateway = true |
One NAT gateway shared across all AZs | ~$41.61/mo |
singleNatGateway = false |
One NAT gateway per AZ (HA — survives AZ failure) | ~$41.61/mo per AZ |
Step 2: Understand the Module¶
The module file is at modules/network.pkl. Here is what it creates and how.
VPC¶
local mainVpc = new vpc.VPC {
label = "\(vars.environment)-talos-vpc"
cidrBlock = vars.vpcCidr
enableDnsHostnames = true
enableDnsSupport = true
tags = makeTags(new Mapping { ["Name"] = "\(vars.environment)-talos-vpc" })
}
label— Formae's internal identifier, built from the environment namecidrBlock— reads fromvars.vpcCidrenableDnsHostnames/enableDnsSupport— required for internal DNS resolution within the VPC
Internet Gateway¶
The Internet Gateway is a separate resource that must be attached to the VPC via a VPCGatewayAttachment:
local mainIgw = new igw.InternetGateway {
label = "\(vars.environment)-talos-igw"
tags = makeTags(new Mapping { ["Name"] = "\(vars.environment)-talos-igw" })
}
local igwAttachment = new igwAttach.VPCGatewayAttachment {
label = "\(vars.environment)-talos-igw-attachment"
vpcId = mainVpc.res.vpcId
internetGatewayId = mainIgw.res.internetGatewayId
}
Note
In AWS CloudFormation (and Formae), InternetGateway has no vpcId property. The attachment is a separate resource. This is a common gotcha when writing Pkl modules.
Public Subnets¶
One public subnet is created per availability zone. These host the NAT gateways and NLB:
local publicSubnets: Listing<subnet.Subnet> = new {
for (_idx, cidr in vars.publicSubnetCidrs) {
new subnet.Subnet {
label = "\(vars.environment)-talos-public-\(vars.availabilityZones[_idx])"
vpcId = mainVpc.res.vpcId
cidrBlock = cidr
availabilityZone = vars.availabilityZones[_idx]
mapPublicIpOnLaunch = true
tags = makeTags(new Mapping {
["Name"] = "\(vars.environment)-talos-public-\(vars.availabilityZones[_idx])"
["Type"] = "public"
["kubernetes.io/role/elb"] = "1"
})
}
}
}
Key points:
- The
forloop iterates overvars.publicSubnetCidrs, creating one subnet per entry _idxis used to look up the matching AZ fromvars.availabilityZonesmapPublicIpOnLaunch = true— instances in public subnets get public IPs- The
kubernetes.io/role/elbtag tells Kubernetes which subnets to use for public load balancers
Private Subnets¶
Private subnets host the Kubernetes nodes. They follow the same pattern but with mapPublicIpOnLaunch = false:
local privateSubnets: Listing<subnet.Subnet> = new {
for (_idx, cidr in vars.privateSubnetCidrs) {
new subnet.Subnet {
label = "\(vars.environment)-talos-private-\(vars.availabilityZones[_idx])"
vpcId = mainVpc.res.vpcId
cidrBlock = cidr
availabilityZone = vars.availabilityZones[_idx]
mapPublicIpOnLaunch = false
tags = makeTags(new Mapping {
["Name"] = "\(vars.environment)-talos-private-\(vars.availabilityZones[_idx])"
["Type"] = "private"
["kubernetes.io/role/internal-elb"] = "1"
})
}
}
}
kubernetes.io/role/internal-elbtag tells Kubernetes which subnets to use for internal load balancers
Elastic IPs and NAT Gateways¶
The number of Elastic IPs and NAT gateways depends on the singleNatGateway setting:
local natEipCount: Int =
if (!vars.enableNatGateway) 0
else if (vars.singleNatGateway) 1
else vars.availabilityZones.length
Each NAT gateway is placed in its corresponding public subnet and receives a dedicated Elastic IP:
local natGateways: Listing<natgw.NatGateway> = new {
for (_idx in IntSeq(0, natEipCount - 1)) {
new natgw.NatGateway {
label = "\(vars.environment)-talos-nat-\(_idx + 1)"
allocationId = natEips[_idx].res.allocationId
subnetId = publicSubnets[_idx].res.subnetId
tags = makeTags(new Mapping {
["Name"] = "\(vars.environment)-talos-nat-\(_idx + 1)"
})
}
}
}
allocationId— references the Elastic IP output (cross-resource resolution viares)subnetId— references the public subnet output
Route Tables¶
Two types of route tables are created:
Public route table — routes internet traffic through the IGW:
local publicDefaultRoute = new route.Route {
label = "\(vars.environment)-public-default"
routeTableId = publicRt.res.routeTableId
destinationCidrBlock = "0.0.0.0/0"
gatewayId = mainIgw.res.internetGatewayId
}
Private route table(s) — routes internet traffic through the NAT gateway. When singleNatGateway = true, one route table is shared; otherwise, one per AZ:
local privateDefaultRoutes: Listing<route.Route> = new {
for (_idx in IntSeq(0, if (vars.enableNatGateway) natEipCount - 1 else -1)) {
new route.Route {
label = "\(vars.environment)-private-default-\(_idx + 1)"
routeTableId = if (vars.singleNatGateway) privateRts[0].res.routeTableId
else privateRts[_idx].res.routeTableId
destinationCidrBlock = "0.0.0.0/0"
natGatewayId = if (vars.singleNatGateway) natGateways[0].res.natGatewayId
else natGateways[_idx].res.natGatewayId
}
}
}
Tag Helper Function¶
Every resource uses a shared makeTags function that merges the common tags from vars.commonTags with resource-specific tags:
local function makeTags(extra: Mapping<String, String>): Listing<aws.Tag> = new {
for (k, v in vars.commonTags) {
new aws.Tag { key = k; value = v }
}
for (k, v in extra) {
new aws.Tag { key = k; value = v }
}
}
Note
Tags must be Listing<aws.Tag> with new { key; value } pairs — not a Mapping spread. This is a Pkl/CloudFormation requirement.
Step 3: Understand Module Exports¶
The network module exports references that other modules consume. You do not need to wire these manually — they are resolved automatically when main.pkl composes all modules:
/// VPC ID for use by other modules (security groups, compute, load balancer).
vpcId = mainVpc.res.vpcId
/// VPC CIDR block for security group rules.
vpcCidr = vars.vpcCidr
/// Public subnet IDs (used by NAT gateways and NLB).
publicSubnetIds = new Listing {
for (sub in publicSubnets) { sub.res.subnetId }
}
/// Private subnet IDs (used by EC2 instances).
privateSubnetIds = new Listing {
for (sub in privateSubnets) { sub.res.subnetId }
}
| Export | Consumed By |
|---|---|
vpcId |
security_groups.pkl, loadbalancer.pkl |
vpcCidr |
security_groups.pkl (CIDR-based ingress rules) |
publicSubnetIds |
loadbalancer.pkl (NLB placement) |
privateSubnetIds |
compute.pkl (EC2 instance placement) |
Step 4: Deploy¶
The network module is deployed as part of the full infrastructure:
Formae resolves all cross-module references and deploys resources in dependency order. You will see the network resources (VPC, subnets, IGW, NAT, routes) created before compute and load balancer resources that depend on them.
Customisation Summary¶
| What to Change | Where | Variable |
|---|---|---|
| VPC address space | vars.pkl |
vpcCidr |
| Number of AZs | vars.pkl |
availabilityZones, publicSubnetCidrs, privateSubnetCidrs |
| NAT gateway strategy | vars.pkl |
singleNatGateway |
| Disable NAT entirely | vars.pkl |
enableNatGateway = false |
| Environment-specific overrides | envs/<name>.pkl |
Amend any of the above |