Egress control & DNS
Default-deny outbound, Cloud NAT, DNS filtering.
Firewalls almost always guard the way in. Almost nobody guards the way out. That's a problem. When an attacker lands on one of your machines, the interesting part hasn't started yet. They still have to phone home for orders and drag your data back out, and both of those are outbound traffic. Give a workload permission to open any connection it likes and you've handed the intruder a free command channel and a free exit for your data. Think of a company mailroom that checks every outgoing package against a list of approved recipients before it leaves the building. That's egress control. This lesson wires up three of those checks on Google Cloud and shows you what a blocked package looks like in the logs.
One door out, and it's watched
Start by giving every machine exactly one way onto the internet. Right now a VM (virtual machine) with a public IP address can talk to the outside straight from its own front door, and you'd have to police every one of those doors separately. Cloud NAT shuts them all and replaces them with a single shared loading dock. NAT stands for Network Address Translation, which is a fancy way of saying 'swap the machine's private internal address for a shared public one on the way out.' The machines keep their private addresses and gain no inbound path, so nobody on the internet can reach in, yet they can still fetch a patch or call an approved API through that one controlled exit. One door means one place to watch and one place to log. For Google's own APIs you often don't need the door at all: turn on Private Google Access and traffic to restricted.googleapis.com (the 199.36.153.4/30 virtual IP range) rides Google's own network instead of the public internet.
# A Cloud Router carries the NAT config for the region.gcloud compute routers create prod-nat-router \--network=prod-vpc --region=us-central1# NAT gateway: all subnets, auto-allocated external IPs, full logging.gcloud compute routers nats create prod-nat \--router=prod-nat-router --region=us-central1 \--nat-all-subnet-ip-ranges \--auto-allocate-nat-external-ips \--enable-logging --log-filter=ALL# --- expected output ---Creating router [prod-nat-router]...done.Creating NAT [prod-nat] in router [prod-nat-router]...done.
Check the address against the allow-list
A single exit isn't much use if it waves everything through. So put a clerk at the loading dock who reads the address on each package and turns away anything not on the approved list. On Google Cloud that clerk is a global network firewall policy, part of Cloud NGFW (Next-Generation Firewall, the newer engine that can match on more than raw IP addresses). Old-style rules could only match on IP addresses, which is hopeless when the service you need hides behind a name that resolves to hundreds of rotating addresses. Cloud NGFW can match on the FQDN instead, the fully qualified domain name, the readable address like api.stripe.com. Here's the part people get wrong. The firewall doesn't watch your machine do the lookup. It resolves those names to IP addresses itself, on its own refresh cycle, using Cloud DNS, and keeps a cached set of addresses per name. Your rule says 'allow to api.stripe.com'; the firewall quietly turns that into 'allow to these IPs.' You write two kinds of rule: allow outbound to the short list of destinations your workload genuinely calls, then deny everything else. One catch to flag now: Google's own domains (storage.googleapis.com and friends) answer with dozens of addresses that churn constantly, so you don't list those by name at all. You send them down the restricted VIP range from the last section and allow that range by IP.
gcloud compute network-firewall-policies create prod-egress-policy \--global --description="egress allow-list"# Rule 1000: allow HTTPS only to approved third-party names (FQDN objects).gcloud compute network-firewall-policies rules create 1000 \--firewall-policy=prod-egress-policy --global-firewall-policy \--direction=EGRESS --action=allow --layer4-configs=tcp:443 \--dest-fqdns=api.stripe.com \--enable-logging# Rule 1100: allow Google APIs by IP, via the restricted Private Google# Access VIP. Their names resolve to too many rotating IPs for FQDN rules.gcloud compute network-firewall-policies rules create 1100 \--firewall-policy=prod-egress-policy --global-firewall-policy \--direction=EGRESS --action=allow --layer4-configs=tcp:443 \--dest-ip-ranges=199.36.153.4/30 \--enable-logging# Rule 2000: deny all other egress, and log it.gcloud compute network-firewall-policies rules create 2000 \--firewall-policy=prod-egress-policy --global-firewall-policy \--direction=EGRESS --action=deny --layer4-configs=all \--dest-ip-ranges=0.0.0.0/0 --enable-logging# Attach the policy to the VPC.gcloud compute network-firewall-policies associations create \--firewall-policy=prod-egress-policy --global-firewall-policy \--network=prod-vpc --name=prod-egress-assoc# --- expected output ---Creating firewall policy prod-egress-policy...done.Creating a rule in the network firewall policy prod-egress-policy...done.Creating a rule in the network firewall policy prod-egress-policy...done.Creating a rule in the network firewall policy prod-egress-policy...done.Creating association prod-egress-assoc...done.
Here's how a packet gets judged. When a machine opens a connection, Cloud NGFW walks the rules in priority order. Rule 1000 checks the destination IP against the set it resolved for your approved names; rule 1100 checks it against the Google VIP range. Match either one on port 443 and the packet is allowed, and evaluation stops there. Miss both and rule 2000 catches it, denies it, and writes a log line. Lower priority numbers get checked first, which is why the allow rules sit at 1000 and 1100 and the catch-all deny sits at 2000. Because the policy is attached to the whole VPC (Virtual Private Cloud, your own private network inside Google Cloud), every machine in it inherits the same allow-list without you touching them one by one. Scale that up by attaching one global policy per environment, or push a hierarchical firewall policy at the folder level so every new project born underneath it starts with egress already locked down. That's the blast-radius win. Compromise one box and the attacker still can't reach anything you didn't approve, in that project or any other.
Block the name before it dials: DNS Firewall
There's one more layer, and it acts before a connection is ever attempted. Almost every outbound call starts by looking up a name. DNS, the Domain Name System, is the internet's phone book: it turns api.stripe.com into an IP address a machine can actually dial. Malware runs the exact same lookup to find its command-and-control server (C2 for short, the box that feeds it orders), and it often smuggles stolen data out inside those lookups, hiding it in long gibberish subdomains that a normal firewall waves straight through. Two Cloud DNS features handle this. A DNS server policy with logging turned on records every question your machines ask, so a data tunnel shows up as a flood of weird names all aimed at one domain. A DNS response policy, also sold as DNS Firewall, is the mailroom's do-not-deliver list: for a name you've flagged as bad it hands back a dead-end answer instead of the real one, so the connection never even gets off the ground.
# Server policy: log every DNS query from the VPC.gcloud dns policies create prod-dns-logging \--networks=prod-vpc --enable-logging \--description="log all resolver queries"# DNS Firewall: a response policy bound to the same VPC.gcloud dns response-policies create prod-dns-firewall \--networks=prod-vpc --description="block known-bad names"# Rule: sinkhole a known C2 domain to a dead-end address.gcloud dns response-policies rules create block-c2 \--response-policy=prod-dns-firewall \--dns-name="malware-c2.example." \--local-data=name="malware-c2.example.",type="A",ttl=300,rrdatas="198.51.100.1"# --- expected output ---Created policy [prod-dns-logging].Created responsePolicy [prod-dns-firewall].Created responsePolicyRule [block-c2].
Controls you can't see aren't controls, they're a wish. The payoff of logging all three layers is that a blocked exit attempt shows up in Cloud Logging as a concrete event you can alert on. Pull the firewall denials and you get the machine that tried, where it was headed, and the exact rule that stopped it.
gcloud logging read \'jsonPayload.disposition="DENIED" AND resource.type="gce_subnetwork"' \--limit=1 --format=json --project=payments-prod# --- expected output ---[{"jsonPayload": {"connection": {"src_ip": "10.20.0.7","src_port": 43210,"dest_ip": "185.220.101.44","dest_port": 443,"protocol": 6},"disposition": "DENIED","rule_details": {"priority": 2000,"action": "DENY","direction": "EGRESS","reference": "network-firewall-policy/prod-egress-policy"}},"resource": { "type": "gce_subnetwork" },"logName": "projects/payments-prod/logs/compute.googleapis.com%2Ffirewall","timestamp": "2026-07-16T09:14:22.481Z"}]
Cloud NAT gives you one door and a place to attach logging. It does not, by itself, decide who is allowed out. Pair it with egress firewall rules that deny 0.0.0.0/0 by default for sensitive tags, then allow only the ports and partners you need. For HTTP(S) heavy estates, a Secure Web Proxy or similar explicit proxy turns destination control into URL and hostname policy instead of brittle IP lists that CDN ranges keep breaking.
DNS Firewall policies catch the moment malware resolves a known-bad name. That is earlier and cheaper than waiting for a full TLS session to an IP you have never seen. Export both firewall and DNS denies into the same sink you use for audit logs so detection rules can fire on either signal.
Try this
From a private VM behind Cloud NAT, prove a blocked destination shows up in the firewall or Secure Web Proxy logs. You want a deny you can quote in an incident channel.
gcloud compute routers nats describe app-nat --router=app-router --region=europe-west1 --project=payments-prod# example: Secure Web Proxy / firewall logging already ongcloud logging read \'resource.type="gce_subnetwork" AND jsonPayload.rule_details.action="DENIED" AND jsonPayload.connection.dest_ip!=""' \--project=payments-prod --limit=3 --format="json"
name: app-natnatIpAllocateOption: MANUAL_ONLYsourceSubnetworkIpRangesToNat: LIST_OF_SUBNETWORKSenableEndpointIndependentMapping: false[{"jsonPayload": {"connection": {"dest_ip": "203.0.113.50", "dest_port": 443, "src_ip": "10.10.2.14"},"rule_details": {"action": "DENIED", "reference": "networktag:egress-restricted"}}}]
Takeaway
Remember: outbound is the attacker command channel. Force egress through NAT or a proxy you log, allow-list destinations that matter, and use DNS Firewall so bad names die before a TCP handshake starts.
Next you will wrap the APIs themselves with VPC Service Controls, because a stolen token that calls storage.googleapis.com from outside your perimeter is still an exfil path even when VMs look locked down.