Skip to content

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:

formae/cluster/aws/vars.pkl
/// VPC CIDR block.
vpcCidr: String = "10.0.0.0/16"

Change this if 10.0.0.0/16 conflicts with your existing networks. For example, the demo environment uses 10.2.0.0/16:

formae/cluster/aws/envs/demo.pkl
// In envs/demo.pkl
vpcCidr = "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:

formae/cluster/aws/vars.pkl
/// 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:

formae/cluster/aws/vars.pkl
/// 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

formae/cluster/aws/modules/network.pkl
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 name
  • cidrBlock — reads from vars.vpcCidr
  • enableDnsHostnames / 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:

formae/cluster/aws/modules/network.pkl
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:

formae/cluster/aws/modules/network.pkl
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 for loop iterates over vars.publicSubnetCidrs, creating one subnet per entry
  • _idx is used to look up the matching AZ from vars.availabilityZones
  • mapPublicIpOnLaunch = true — instances in public subnets get public IPs
  • The kubernetes.io/role/elb tag 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:

formae/cluster/aws/modules/network.pkl
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-elb tag 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:

formae/cluster/aws/modules/network.pkl
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:

formae/cluster/aws/modules/network.pkl
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 via res)
  • subnetId — references the public subnet output

Route Tables

Two types of route tables are created:

Public route table — routes internet traffic through the IGW:

formae/cluster/aws/modules/network.pkl
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:

formae/cluster/aws/modules/network.pkl
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:

formae/cluster/aws/modules/network.pkl
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:

formae/cluster/aws/modules/network.pkl
/// 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:

cd formae/cluster/aws
formae apply --mode reconcile --ami $AMI main.pkl

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