CoursesGCP securityEgress control & DNS

Egress control & DNS

Default-deny outbound, Cloud NAT, DNS filtering.

Advanced30 min · lesson 5 of 15

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.

one controlled exit for the whole subnet
# 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.

FQDN allow-list, Google APIs by VIP, deny the rest
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.

log every lookup, sinkhole the bad ones
# 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.

a blocked egress attempt, straight from the logs
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"
}
]
Egress path: one exit, checked and logged
private workloads
VMs in prod-vpc
no external IPs, no inbound path
one controlled exit
Cloud NAT
single shared egress, every flow logged
address checks at the door
Cloud NGFW rules
allow api.stripe.com + Google VIP, deny 0.0.0.0/0
DNS Firewall
sinkhole known-bad names before dial
the record
firewall + DNS query logs
DENIED events to Cloud Logging, then alert
Every outbound packet takes one path, gets its address checked against the allow-list, and leaves a log line. A compromised box can't phone home to anything you didn't approve. What this still won't catch is a valid token walking data out through an allowed API, which is where VPC Service Controls comes in.
Default-deny will strangle your own machines first
The first time you flip on deny-all egress, expect breakage that looks nothing like an attack. VMs can't pull OS patches, the Ops Agent stops shipping logs, container images won't download, and package installs hang. All of it is outbound traffic to somewhere you forgot to allow. One surprise cuts the other way: DNS through Google's default resolver keeps working no matter what your rules say, because traffic to the metadata server at 169.254.169.254 ignores firewall rules entirely, so don't expect to catch your own lookups in the DENIED logs unless you run a custom resolver. The trap that actually bites is FQDN objects. The firewall resolves those names on its own clock, so for anything behind a big CDN, or for Google's own domains that answer with dozens of churning IPs, the address your VM dials can drift out of the firewall's cached set and get denied even though the name is on your list. Use the restricted VIP range or an address group for those, and keep FQDN rules for stable endpoints. Remember too that the allow-list is only as honest as your DNS: if a trusted name can be repointed, the allow follows it, so keep your response-policy sinkholes and private zones authoritative. Roll the whole thing out in a staging VPC first, watch the DENIED logs, and add the boring-but-required destinations (the Google APIs VIP, your package mirrors, Artifact Registry) before you enforce in prod.

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.

terminal
gcloud compute routers nats describe app-nat --router=app-router --region=europe-west1 --project=payments-prod
# example: Secure Web Proxy / firewall logging already on
gcloud logging read \
'resource.type="gce_subnetwork" AND jsonPayload.rule_details.action="DENIED" AND jsonPayload.connection.dest_ip!=""' \
--project=payments-prod --limit=3 --format="json"
output
name: app-nat
natIpAllocateOption: MANUAL_ONLY
sourceSubnetworkIpRangesToNat: LIST_OF_SUBNETWORKS
enableEndpointIndependentMapping: 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.

Quick check
01Your FQDN rule allows egress to api.stripe.com and you deny everything else. The VM resolves the name and connects, but the connection still logs as DENIED against the deny rule. What's the most likely cause?
Correct — FQDN objects don't watch your VM's lookups; the firewall resolves the name itself and caps each name at a fixed set of addresses. For endpoints behind big CDNs, or Google's own domains that answer with dozens of IPs, use an address group or the restricted Private Google Access VIP range instead of a name.
Incorrect — They're supported. --dest-fqdns is a real match condition in global network firewall policies; when the dialed IP is in the cached set, the allow rule works fine.
Incorrect — NAT rewrites the source address on the way out, not the destination. The firewall still sees Stripe's real destination IP.
Incorrect — Lower priority numbers are checked first, so 1000 (allow) is evaluated before 2000 (deny). Priority order isn't the problem here.
02On top of the egress firewall, the lesson adds a Cloud DNS (Domain Name System) response policy, also sold as DNS Firewall. How does it stop a workload from reaching a known-bad domain?
Correct — a response policy acts before any connection, answering flagged names with a do-not-deliver address.
Incorrect — that describes a firewall rule acting after name resolution; the response policy steps in earlier, at lookup time.
Incorrect — it does not encrypt lookups; it overrides the answer for names you have flagged.
Incorrect — it is not a rate limiter; it substitutes answers for specific bad names.
03The morning after you switch on default-deny egress in production, machines can't pull operating-system patches, the Ops Agent stops shipping logs, and container-image pulls hang - yet nothing looks like an attack. What is happening, and how should this have been rolled out?
Incorrect — this is your own legitimate traffic hitting the catch-all deny, not attacker activity.
Incorrect — NAT is not the blocker here; the egress allow-list is denying these specific destinations.
Correct — default-deny egress predictably breaks patching, logging, and image pulls until you add the boring-but-required destinations.
Incorrect — metadata-server traffic bypasses firewall rules entirely, so it is not what is blocking patches and image pulls.

Related