VPC Network Design with Cloud NAT

Cloud & Infrastructure intermediate Building with AI ~20 min read

Overview

A Virtual Private Cloud (VPC) is the foundational networking construct in Google Cloud Platform. It defines the private IP address space, subnets, routing rules, and firewall policies that govern how workloads communicate with each other and the outside world. Unlike the default auto-mode VPC that GCP creates for new projects, production environments demand custom-mode VPCs with explicitly designed subnet topology, secondary IP ranges for GKE pods and services, and carefully scoped firewall rules that follow least-privilege principles.

Cloud NAT (Network Address Translation) solves a specific problem for private clusters: GKE nodes and pods that have only private IP addresses still need outbound internet access to pull container images, reach external APIs, and download dependencies. Cloud NAT provides managed, highly available source NAT for outbound traffic without requiring any public IP addresses on the nodes themselves. It operates at the Cloud Router level, automatically scaling NAT IP addresses based on connection volume, and requires zero configuration on the workloads.

As a practical example, an infrastructure codebase can provision a custom VPC for each environment with primary subnets for GKE nodes and secondary ranges for pods and services. Cloud Router and Cloud NAT are configured per region to provide outbound connectivity for the private GKE Autopilot clusters. Firewall rules are scoped by service account and port, ensuring that only the search API can reach the vector database port, only the billing service can reach Stripe’s webhook endpoints, and so on.

Core Concepts

Custom-Mode VPC

A custom-mode VPC starts with no subnets—every subnet must be explicitly created with a defined IP range, region, and purpose. This is the correct choice for production because it prevents IP range conflicts, allows precise CIDR planning for multi-environment setups, and ensures that secondary ranges for GKE are sized appropriately for the expected pod count (each secondary range determines the maximum number of pods per node).

Primary and Secondary Subnet Ranges

GKE requires three IP ranges: a primary range for node IPs, a secondary range for pod IPs, and a secondary range for service IPs (ClusterIPs). A production IaC setup uses /20 for nodes (4,094 addresses), /14 for pods (262,142 addresses), and /20 for services (4,094 addresses). The pod range is deliberately oversized because GKE Autopilot allocates up to 256 pods per node, and each pod gets its own IP from the secondary range.

Cloud Router

Cloud Router is a fully managed regional resource that provides dynamic routing via BGP. In the context of Cloud NAT, it serves as the control plane that manages NAT mappings, advertises routes, and coordinates NAT IP allocation. Each Cloud NAT gateway is associated with exactly one Cloud Router, and a single Cloud Router can serve multiple NAT gateways (though in practice, one per region is typical). Cloud Router also enables VPN and Interconnect routing for hybrid connectivity.

Cloud NAT with Auto-Allocated IPs

Cloud NAT translates outbound connections from private instances to the internet using a pool of external IP addresses. With natIpAllocateOption: AUTO_ONLY, GCP automatically provisions and manages these IPs, scaling the pool as connection volume grows. Cloud NAT supports configurable minimum ports per VM, UDP/TCP idle timeouts, and endpoint-independent mapping for protocols that require it. It does not support inbound NAT—inbound traffic must flow through load balancers.

Firewall Rules with Service Account Targeting

GCP firewall rules can target traffic by network tag, service account, or IP range. Service account targeting is the most secure option for GKE workloads because it binds firewall policies to the identity of the workload rather than a mutable tag. A production IaC setup creates deny-all ingress as a baseline and then adds specific allow rules per service pair (e.g., allow TCP 5432 from the API service account to the database service account).

How It Works

The infrastructure project provisions the complete network stack as Pulumi resources: VPC, subnets with secondary ranges, Cloud Router, Cloud NAT, and firewall rules. Each environment gets its own VPC with non-overlapping CIDR ranges to support VPC peering if needed.

VPC and Subnet Provisioning

The custom VPC is created with auto-subnet creation disabled. Subnets are then explicitly defined with primary and secondary ranges sized for GKE.

package infra

import (
    "fmt"

    "github.com/pulumi/pulumi-gcp/sdk/v7/go/gcp/compute"
    "github.com/pulumi/pulumi/sdk/v3/go/pulumi"
)

type VPCConfig struct {
    Project     string
    Region      string
    Environment string
    NodeCIDR    string // e.g. "10.0.0.0/20"
    PodCIDR     string // e.g. "10.4.0.0/14"
    ServiceCIDR string // e.g. "10.8.0.0/20"
}

func NewVPCNetwork(ctx *pulumi.Context, cfg VPCConfig) (*compute.Network, *compute.Subnetwork, error) {
    network, err := compute.NewNetwork(ctx, cfg.Environment+"-vpc", &compute.NetworkArgs{
        Name:                  pulumi.String(fmt.Sprintf("%s-vpc", cfg.Environment)),
        Project:               pulumi.String(cfg.Project),
        AutoCreateSubnetworks: pulumi.Bool(false),
        RoutingMode:           pulumi.String("REGIONAL"),
    })
    if err != nil {
        return nil, nil, err
    }

    subnet, err := compute.NewSubnetwork(ctx, cfg.Environment+"-subnet", &compute.SubnetworkArgs{
        Name:                 pulumi.String(fmt.Sprintf("%s-nodes", cfg.Environment)),
        Project:              pulumi.String(cfg.Project),
        Region:               pulumi.String(cfg.Region),
        Network:              network.ID(),
        IpCidrRange:          pulumi.String(cfg.NodeCIDR),
        PrivateIpGoogleAccess: pulumi.Bool(true),
        SecondaryIpRanges: compute.SubnetworkSecondaryIpRangeArray{
            &compute.SubnetworkSecondaryIpRangeArgs{
                RangeName:   pulumi.String("pods"),
                IpCidrRange: pulumi.String(cfg.PodCIDR),
            },
            &compute.SubnetworkSecondaryIpRangeArgs{
                RangeName:   pulumi.String("services"),
                IpCidrRange: pulumi.String(cfg.ServiceCIDR),
            },
        },
    })
    if err != nil {
        return nil, nil, err
    }

    return network, subnet, nil
}

Cloud Router and Cloud NAT

The Cloud Router is created in the same region as the subnet. Cloud NAT is attached to the router and configured to NAT all traffic from all subnets using auto-allocated external IPs.

func NewCloudNAT(ctx *pulumi.Context, env string, cfg VPCConfig, network *compute.Network) error {
    router, err := compute.NewRouter(ctx, env+"-router", &compute.RouterArgs{
        Name:    pulumi.String(fmt.Sprintf("%s-router", env)),
        Project: pulumi.String(cfg.Project),
        Region:  pulumi.String(cfg.Region),
        Network: network.ID(),
    })
    if err != nil {
        return err
    }

    _, err = compute.NewRouterNat(ctx, env+"-nat", &compute.RouterNatArgs{
        Name:                          pulumi.String(fmt.Sprintf("%s-nat", env)),
        Project:                       pulumi.String(cfg.Project),
        Region:                        pulumi.String(cfg.Region),
        Router:                        router.Name,
        NatIpAllocateOption:           pulumi.String("AUTO_ONLY"),
        SourceSubnetworkIpRangesToNat: pulumi.String("ALL_SUBNETWORKS_ALL_IP_RANGES"),
        MinPortsPerVm:                pulumi.Int(64),
        EnableEndpointIndependentMapping: pulumi.Bool(false),
        LogConfig: &compute.RouterNatLogConfigArgs{
            Enable: pulumi.Bool(true),
            Filter: pulumi.String("ERRORS_ONLY"),
        },
    })
    return err
}

Firewall Rules

The infrastructure project creates a deny-all baseline and then allows specific traffic patterns. Each allow rule targets a service account and specifies exact ports.

func NewFirewallRules(ctx *pulumi.Context, env string, network *compute.Network) error {
    // Deny all ingress by default (lower priority than specific allows)
    _, err := compute.NewFirewall(ctx, env+"-deny-all-ingress", &compute.FirewallArgs{
        Name:     pulumi.String(fmt.Sprintf("%s-deny-all-ingress", env)),
        Network:  network.ID(),
        Priority: pulumi.Int(65534),
        Direction: pulumi.String("INGRESS"),
        Denies: compute.FirewallDenyArray{
            &compute.FirewallDenyArgs{
                Protocol: pulumi.String("all"),
            },
        },
        SourceRanges: pulumi.StringArray{pulumi.String("0.0.0.0/0")},
    })
    if err != nil {
        return err
    }

    // Allow internal traffic within the VPC
    _, err = compute.NewFirewall(ctx, env+"-allow-internal", &compute.FirewallArgs{
        Name:     pulumi.String(fmt.Sprintf("%s-allow-internal", env)),
        Network:  network.ID(),
        Priority: pulumi.Int(1000),
        Direction: pulumi.String("INGRESS"),
        Allows: compute.FirewallAllowArray{
            &compute.FirewallAllowArgs{
                Protocol: pulumi.String("tcp"),
            },
            &compute.FirewallAllowArgs{
                Protocol: pulumi.String("udp"),
            },
            &compute.FirewallAllowArgs{
                Protocol: pulumi.String("icmp"),
            },
        },
        SourceRanges: pulumi.StringArray{
            pulumi.String("10.0.0.0/8"),
        },
    })
    if err != nil {
        return err
    }

    // Allow health checks from GCP load balancer ranges
    _, err = compute.NewFirewall(ctx, env+"-allow-health-checks", &compute.FirewallArgs{
        Name:     pulumi.String(fmt.Sprintf("%s-allow-health-checks", env)),
        Network:  network.ID(),
        Priority: pulumi.Int(1000),
        Direction: pulumi.String("INGRESS"),
        Allows: compute.FirewallAllowArray{
            &compute.FirewallAllowArgs{
                Protocol: pulumi.String("tcp"),
                Ports:    pulumi.StringArray{pulumi.String("80"), pulumi.String("443"), pulumi.String("8080")},
            },
        },
        SourceRanges: pulumi.StringArray{
            pulumi.String("35.191.0.0/16"),
            pulumi.String("130.211.0.0/22"),
        },
    })
    return err
}

Implementation Patterns

CIDR Planning for Multi-Environment Isolation

A production IaC setup assigns non-overlapping CIDR blocks per environment: dev uses 10.0.0.0/16, staging uses 10.1.0.0/16, and production uses 10.2.0.0/16. This strategy enables VPC peering between environments if needed (e.g., for a staging service to read from a production read replica) without IP conflicts. The pod and service secondary ranges follow the same pattern with offset blocks in the 10.4.0.0/12 space.

Private Google Access

Enabling privateIpGoogleAccess on subnets allows VMs and pods with only private IPs to reach Google APIs (Cloud Storage, BigQuery, Container Registry) without going through Cloud NAT. This is both faster (traffic stays on Google’s backbone) and cheaper (no NAT bandwidth charges for Google API traffic). A production IaC setup enables this on all subnets.

NAT Port Allocation Strategy

Cloud NAT allocates a minimum number of source ports per VM for connection tracking. The default of 64 ports per VM is sufficient for most workloads, but services that make many concurrent outbound connections (like a crawler or a batch API client) may exhaust their port allocation. A production IaC setup monitors the nat/allocated_ports metric and adjusts minPortsPerVm per environment based on observed usage.

Practical Applications

The VPC and Cloud NAT configuration underpins every networked service in a cloud-native platform:

Common Pitfalls

Further Reading