Segmentation & private connectivity
VPC design, PrivateLink/Private Service Connect, no public paths.
A cargo ship stays afloat after a hull breach because steel bulkheads split the hull into sealed compartments. Water floods one compartment, the doors seal, and the rest of the ship keeps moving. A cloud network wants that same shape. Identity is the lock on each door. Segmentation is the bulkhead behind it, so that when one lock finally gets picked, the flooding stops at one room instead of sinking the whole boat.
Assume a credential will leak. At real scale, one always does. What happens after the leak is decided by the shape of your network, not by how careful you were. Tight segmentation and private connectivity are the gap between an attacker owning one compromised service and an attacker walking the entire estate. The posture you want is default-closed: no path exists until you deliberately open the narrowest one a workload actually needs.
Two directions of traffic matter, and they fail differently. North-south is traffic between your estate and the outside world: a customer hitting your edge load balancer, or a workload calling a payments API. East-west is service-to-service traffic inside your own networks, the hallway traffic between rooms. East-west is where real intrusions spread. An attacker lands on one host, then feels along the walls for a database, a cache, or the metadata endpoint (the Instance Metadata Service, or IMDS, a small server at 169.254.169.254 that hands out cloud credentials to whatever asks from that host). Segmentation clamps down on east-west. Private connectivity removes a large slice of north-south by keeping managed-service traffic off the public internet entirely. Microsegmentation takes it to the limit: every tier, sometimes every single workload, gets its own list of allowed callers instead of sharing one flat subnet where everything can reach everything.
Segment east-west by identity, not by CIDR
Writing firewall rules as IP address ranges (CIDR notation, the 10.0.1.0/24 style blocks that each stand for a run of addresses) rots the moment you save it. Instances churn. Subnets fill and get carved up. You end up hand-maintaining address lists that drift away from what is actually running, and every drift is either an outage or a hole. Every major cloud lets you point a rule at workload identity instead, and then the rule stops caring about addresses at all.
On AWS, a security group rule names another security group as its source, so "the database accepts PostgreSQL on port 5432 (the numbered door PostgreSQL listens on) from the app tier" is written as group membership, not addresses. On GCP, a VPC (Virtual Private Cloud, your own walled-off network inside the provider) firewall rule matches the source and target service accounts, so the identity is the service account attached to the virtual machine. On Azure, Application Security Groups (ASGs, labels you stick on network cards to sort them into tiers) let a network security group (NSG) rule reference a tier on both ends. In all three, scaling from two nodes to two hundred never touches the rule. New members inherit the tier's reachability the instant they join, and there is no address list to widen or to forget to prune.
# The DB security group accepts 5432 ONLY from the app security group.# The rule names an identity (a group), never an IP range.$ aws ec2 authorize-security-group-ingress \--group-id sg-0db1234567890abcd \--ip-permissions 'IpProtocol=tcp,FromPort=5432,ToPort=5432,UserIdGroupPairs=[{GroupId=sg-0app234567890abcd,Description="app tier"}]'
{"Return": true,"SecurityGroupRules": [{"SecurityGroupRuleId": "sgr-0a1b2c3d4e5f67890","GroupId": "sg-0db1234567890abcd","GroupOwnerId": "123456789012","IsEgress": false,"IpProtocol": "tcp","FromPort": 5432,"ToPort": 5432,"ReferencedGroupInfo": {"GroupId": "sg-0app234567890abcd","UserId": "123456789012"},"Description": "app tier"}]}
# The firewall rule matches service-account identity on both ends.$ gcloud compute firewall-rules create allow-app-to-db \--network=prod-vpc --direction=INGRESS --action=ALLOW --rules=tcp:5432 \
Creating firewall...done.NAME NETWORK DIRECTION PRIORITY ALLOW DENY DISABLEDallow-app-to-db prod-vpc INGRESS 1000 tcp:5432 False
# The NSG rule references Application Security Groups, not addresses.$ az network nsg rule create -g prod-rg --nsg-name db-nsg -n allow-app-to-db \--priority 100 --direction Inbound --access Allow --protocol Tcp \--destination-port-ranges 5432 --source-asgs app-asg --destination-asgs db-asg \--query "{name:name, access:access, port:destinationPortRange, state:provisioningState}"
{"access": "Allow","name": "allow-app-to-db","port": "5432","state": "Succeeded"}
These references are enforced down in the data plane, not by a sidecar proxy (a helper process that runs beside your app) that a compromised process could sidestep. AWS enforces security-group membership in the Nitro system, the dedicated networking hardware that sits under each instance, below anything the guest operating system can reach. GCP enforces it in Andromeda, its network virtualization stack. Security groups, NSG rules, and GCP firewall rules are all stateful: allow the request and the return packet comes back on its own, with no second rule to write.
AWS network ACLs (NACLs, access control lists that sit at the subnet edge) are the exception. They are stateless, so you write both directions by hand, which is exactly why a NACL belongs as a coarse subnet-wide backstop and not as your primary control. Watch the ceilings, too. A security group allows 60 inbound and 60 outbound rules by default, and the rules-per-group multiplied by groups-per-network-interface cannot exceed 1000. You can reference a security group in a peered VPC, but only when both VPCs sit in the same region; cross-region peering does not carry group references. So at estate scale you consolidate into a handful of well-named tiers on purpose, instead of minting one group per microservice and slamming into the quota.
Reach managed services over the provider backbone
A managed database, a secrets vault, or a partner API usually ships with a public front door on the internet. You do not have to reach it from the street. It works like a private service tunnel that drops the shop's counter directly inside your own building. PrivateLink on AWS, Private Service Connect on GCP, and Private Endpoints on Azure each project the remote service into your subnet as a network interface holding an address from your own range.
Your workloads dial that private IP. The traffic rides the provider's backbone and never touches a public route. That deletes a whole class of exposure. There is no internet path left to misconfigure, no NAT gateway (network address translation, the box that lets private hosts reach outward) billing and logging the traffic, and access is decided by your network policy plus IAM (identity and access management, who is allowed to do what) rather than by whoever can resolve a public DNS (Domain Name System, the internet's name-to-address lookup) name. The service keeps its public endpoint alive, but nothing inside your VPC is wired to use it, so a stray public route cannot quietly re-expose the path.
# An interface endpoint for Secrets Manager, with private DNS switched on.$ aws ec2 create-vpc-endpoint --vpc-id vpc-0abc123 \--vpc-endpoint-type Interface \--service-name com.amazonaws.us-east-1.secretsmanager \--subnet-ids subnet-0priv1 subnet-0priv2 \--security-group-ids sg-0endpoint \--private-dns-enabled
{"VpcEndpoint": {"VpcEndpointId": "vpce-0a1b2c3d4e5f6a7b8","VpcEndpointType": "Interface","VpcId": "vpc-0abc123","ServiceName": "com.amazonaws.us-east-1.secretsmanager","State": "pending","PrivateDnsEnabled": true,"SubnetIds": ["subnet-0priv1", "subnet-0priv2"],"NetworkInterfaceIds": ["eni-0aa11c2d3e4f5a6b7", "eni-0bb22d3e4f5a6b7c8"],"CreationTimestamp": "2026-07-22T10:15:03+00:00"}}
# Reserve an internal IP, then point a forwarding rule at the published# service's attachment (e.g. Cloud SQL). Traffic now rides the backbone.$ gcloud compute addresses create psc-sql-ip --region=us-central1 \--subnet=priv-subnet --addresses=10.20.1.10$ gcloud compute forwarding-rules create psc-cloudsql --region=us-central1 \--network=prod-vpc --address=psc-sql-ip \--target-service-attachment=projects/sqlprod/regions/us-central1/serviceAttachments/sql-sa
Created [https://www.googleapis.com/compute/v1/projects/prod/regions/us-central1/addresses/psc-sql-ip].Created [https://www.googleapis.com/compute/v1/projects/prod/regions/us-central1/forwardingRules/psc-cloudsql].
# Project Key Vault into a private subnet as a private endpoint.$ az network private-endpoint create -g prod-rg -n pe-vault \--vnet-name prod-vnet --subnet priv-subnet \--private-connection-resource-id "/subscriptions/<sub>/resourceGroups/prod-rg/providers/Microsoft.KeyVault/vaults/prod-kv" \--group-id vault --connection-name pe-vault-conn \--query "{name:name, state:provisioningState}"
{"name": "pe-vault","state": "Succeeded"}
Provisioning the interface gives you the path. Deciding who may travel it is a separate job. An AWS interface endpoint takes an endpoint policy, a small permissions document, so a caller who reaches the private IP still only touches the specific secrets or buckets you name, not every secret in the account. On Azure, the subnet's network security group and the target resource's own firewall both still apply. On GCP, the service attachment on the producer side controls which consumer projects may even connect. And at the org level, AWS resource control policies (RCPs, a ceiling on what any principal may do to your resources) can require that a bucket or a secret is only ever reached through your own endpoints, so a leaked credential used from a coffee-shop laptop still gets refused. The private path is reachability; identity and these policies are authorization. You want both.
Do not confuse Azure Private Endpoints with the older Service Endpoints. A service endpoint keeps the service's public IP but pins access to your subnet using the service's own firewall. A private endpoint gives you a private IP inside your subnet and takes the public IP out of the path entirely. When the requirement is "no public IP anywhere in the flow," only the private endpoint gets you there.
Prove the path, do not assume it
Trust the output, not the intent. Two checks catch nearly every broken segmentation change: does the managed service resolve to a private address from inside, and does an unauthorized caller actually get refused.
# From inside the VPC, the service FQDN must resolve to a PRIVATE address.$ dig +short secretsmanager.us-east-1.amazonaws.com
10.0.12.47
# From inside the VNet, the vault name should chain through the privatelink zone.$ nslookup prod-kv.vault.azure.net
Server: 127.0.0.53Address: 127.0.0.53#53Non-authoritative answer:prod-kv.vault.azure.net canonical name = prod-kv.privatelink.vaultcore.azure.net.Name: prod-kv.privatelink.vaultcore.azure.netAddress: 10.20.1.5
Now the negative test, the one people skip. Run it from a bastion (the single hardened jump host you are allowed to log into) that is not a member of the app tier: the database port should hang and time out. Run the same probe from a host that is in the app tier: it should connect at once. If both succeed, your rule is wider than you think, and you have found the hole before an attacker did.
# Segmentation only works if the wrong caller fails.# From the bastion (NOT a member of the app tier):$ nc -vz -w 5 db.internal 5432# From app-01 (a member of the app tier):$ nc -vz db.internal 5432
# bastion: the connection request (the TCP SYN, the first packet of the# handshake) is dropped by the identity rule, no reset comes back,# so the connection times out instead of being refused.nc: connect to db.internal port 5432 (tcp) timed out: Operation now in progress# app-01: allowed by the identity rule.Connection to db.internal 5432 port [tcp/postgresql] succeeded!
Kill the default-open paths and hold them shut
A default VPC exists so a tutorial's hello-world can reach the internet with zero setup: public subnets, an internet gateway, permissive rules. That is the exact opposite of a production posture. Build networks deliberately, with private subnets that carry no gateway route, egress scoped on purpose, and identity-referenced rules from day one. Then delete or quarantine the default VPC so production cannot accrete inside it by accident.
Deleting it once is a moment in time. Stopping the next engineer from re-opening a public path next quarter is a standing guardrail, and each cloud has an org-level one that inherits down to every account or project below it. On AWS, a service control policy (SCP, an account-wide ceiling on what anyone may do) can deny creating or attaching an internet gateway; you create the policy, then attach it to the org root or an organizational unit to make it bite. On GCP, an org policy can deny external IPs on virtual machines everywhere at once. On Azure, Azure Policy can deny public IPs on network interfaces. Set it high, and no one downstream can undo it with a console click.
{"Version": "2012-10-17","Statement": [{"Sid": "DenyInternetGateways","Effect": "Deny","Action": ["ec2:CreateInternetGateway","ec2:AttachInternetGateway"],"Resource": "*"}]}
# Create the SCP; attach it to the root or an OU afterwards to enforce it.$ aws organizations create-policy --type SERVICE_CONTROL_POLICY \--name deny-igw --description "No new internet gateways" \--content file://deny-igw.json
{"Policy": {"PolicySummary": {"Id": "p-a1b2c3d4","Arn": "arn:aws:organizations::123456789012:policy/o-abc123def4/service_control_policy/p-a1b2c3d4","Name": "deny-igw","Description": "No new internet gateways","Type": "SERVICE_CONTROL_POLICY","AwsManaged": false}}}
# Deny external IPs on every VM under the organization.$ gcloud resource-manager org-policies deny compute.vmExternalIpAccess all \--organization=123456789012
constraint: constraints/compute.vmExternalIpAccesslistPolicy:allValues: DENYetag: BwYABc12DeF=updateTime: '2026-07-22T10:20:14.482Z'
# Assign the built-in "Network interfaces should not have public IPs".# Its effect is a fixed Deny in the definition: there is no effect parameter# to set, and it does not default to Audit, so assigning it is enough.$ az policy assignment create --name deny-nic-public-ip \--display-name "Deny public IPs on NICs" \--scope "/subscriptions/<sub>" \--policy 83a86a26-fd1f-447c-b59d-e51f44264114 \--query "{displayName:displayName, enforcementMode:enforcementMode, name:name, policyDefinitionId:policyDefinitionId, scope:scope}"
{"displayName": "Deny public IPs on NICs","enforcementMode": "Default","name": "deny-nic-public-ip","policyDefinitionId": "/providers/Microsoft.Authorization/policyDefinitions/83a86a26-fd1f-447c-b59d-e51f44264114","scope": "/subscriptions/00000000-0000-0000-0000-000000000000"}
A guardrail like this stops the accidental public path. It does not decide which approved names a workload may reach on the way out for operating-system patches, package registries, and SaaS (software you rent over the web) webhooks. Governing that outbound flow, resolving only the names you allow and dropping the rest at an egress firewall, is the next lesson.
Try this
Run dig +short secretsmanager.us-east-1.amazonaws.com on a scratch host or disposable cluster and read the output against what this lesson described. Then change one input so it fails, and re-run: the error you get is the one you will meet in production.
Takeaway
The trap worth remembering here: a private endpoint with public DNS is a no-op. Check that on your own systems before you need to, because it is cheaper to find on a quiet afternoon than during an incident.