Building a Detection-First SOC Lab on GCP
I wanted to understand a documented Azure hybrid-SOC pattern well enough to rebuild it — not read about it, actually build it. So I re-platformed it to GCP with open-source substitutes: Wazuh instead of Sentinel, Tailscale instead of a VPN gateway, a plain Linux box instead of a commercial network virtual appliance. Everything is provisioned as code with OpenTofu and Ansible, designed to be spun up for a session and torn down afterward rather than run 24/7.
This post covers what's actually built and working: a network with no standing public IPs except one deliberately exposed target, a private SIEM, a vulnerable web app shipping logs into it, a custom detection rule, and an edge router that publishes that target to the real internet — so an "attack" against it is genuine internet traffic, not a synthetic replay. Call it Part 1: expose → detect. The response half — auto-blocking, case management — is Part 2, and I'll say clearly where that line is.
Design Principles
A few constraints shaped every decision:
- Ephemeral by default. The lab isn't meant to run continuously —
make applyandmake destroyare both first-class operations. - No standing public IPs except the one thing that's meant to be internet-facing.
- IaC-only. No clickops. If it's not in Terraform or Ansible, it doesn't exist.
- Bastion-less access. No jump box sitting around as a single point of failure and a juicy target in its own right.
The Network
One VPC, 10.10.0.0/16, split into three purpose-built subnets:
| Subnet | CIDR | Holds |
|---|---|---|
soc | 10.10.10.0/24 | Tailscale subnet router, Wazuh SIEM (all-in-one) |
targets | 10.10.20.0/24 | DVWA (deliberately vulnerable web app) |
edge | 10.10.30.0/24 | Edge NVA (Ubuntu + nftables) |
No VM has a public IP except the edge router, and even the edge only exposes one thing: TCP/80, DNAT'd to DVWA, locked to my own IP via a firewall rule scoped to a /32. Every other VM is reached bastion-less over a Tailscale subnet router — no jump box — with IAP TCP forwarding as a fallback path for SSH (Google's fixed IAP range, 35.235.240.0/20) that works even before Tailscale is up.
Egress is split by role. General internet-bound traffic from private VMs goes out through Cloud NAT. DVWA is the one exception: it's tagged via-edge, and a custom route (priority 900, tag-scoped) sends its egress through the edge NVA instead. The tag scoping matters — it keeps the edge box itself on its default route, avoiding a self-referential routing loop.
Applying the full config creates 27 resources: the VPC, 3 subnets, 3 firewall rules, Cloud Router + NAT, 4 service accounts, and 4 VMs (ts-router e2-small, soc-server e2-standard-4, dvwa e2-small, edge e2-small). State lives in GCS (soc-lab-tfstate-v1); secrets like the Tailscale auth key are injected via .env and TF_VAR_*, never committed. A Makefile wraps init/plan/apply/destroy/fmt/validate.
The Build, Increment by Increment
The lab was built as a sequence of git commits, each one proving a specific thing before moving on:
- Bootstrap. GCS remote state and core GCP APIs (
compute,iam,secretmanager,iap,oslogin,storage, …) enabled once up front, withdisable_on_destroy = false— so a tear-down/rebuild cycle doesn't pay the slow API re-enable tax every time. - Network foundation. The VPC, three subnets, and base firewall (allow-internal east-west, IAP SSH fallback), with
private_ip_google_access = trueso private VMs can reach*.googleapis.comfor free, without NAT. - Bastion-less access. A Tailscale subnet router VM plus Cloud Router/Cloud NAT. The router advertises
10.10.0.0/16into the tailnet. Cloud NAT is the first billed resource in the stack, at roughly $0.044/hr. - Proving the route, not just the tailnet. A throwaway test VM confirmed that the subnet route reaches a host that does not itself run Tailscale — because the real services (Wazuh, DVWA) wouldn't run the Tailscale client either. Worth proving before building anything that depends on it.
- The SIEM. Wazuh (manager + indexer + dashboard, all-in-one) on Ubuntu 22.04, e2-standard-4. 22.04 is pinned deliberately — Wazuh 4.9 doesn't officially support 24.04, the environment's default image. The dashboard is reached over the tailnet on :443, with no public IP at all.
- The target. DVWA (
vulnerables/web-dvwa, in Docker) plus a Wazuh agent that auto-enrolls to the manager. - Log shipping and the first detection rule. Ansible ships DVWA's Apache logs into Wazuh, and rule
100100flags SQLi/LFI/XSS URL patterns, mapped to MITRE T1190. - The edge NVA. A plain Ubuntu box with nftables (
can_ip_forward = true) and an ephemeral public IP, with DVWA's egress rerouted through it. - Going live. nftables DNAT forwards inbound
:80on the edge's public IP to DVWA — deliberately without SNAT on that flow — and a firewall rule locks that port to my own/32.
Two Gotchas Worth Knowing About
The Wazuh agent version trap
A Wazuh manager silently rejects any agent newer than itself. The first attempt at enrolling the DVWA agent installed whatever version apt resolved to by default, and it just didn't show up as connected — no obvious error, just silence. The fix was to dynamically resolve the exact matching patch version at install time with apt-cache madison and pin to the manager's major.minor line, rather than hardcoding a version and hoping it stayed in sync.
Docker and idempotent config management don't mix well for volumes
Shipping DVWA's Apache logs into Wazuh meant bind-mounting its log directory — but Docker won't let you hot-attach a volume to a running container. The fix was to have Ansible recreate the DVWA container with the mount in place, rather than trying to patch it live. A low-stakes tradeoff, since DVWA is stateless anyway and losing its in-container state costs nothing.
The Detection Rule
Rule 100100 lives in Wazuh's local ruleset and matches on URL patterns commonly seen in SQLi, LFI, and XSS attempts:
<group name="local,web,attack,">
<rule id="100100" level="10">
<if_group>web</if_group>
<url type="pcre2">(?i)(union\s+select|information_schema|\.\./|/etc/passwd|<script)</url>
<description>Possible web attack (SQLi/LFI/XSS) against DVWA</description>
<mitre>
<id>T1190</id>
</mitre>
</rule>
</group>
It sits under the web parent group, so it only evaluates against web access logs, and maps to MITRE T1190 (Exploit Public-Facing Application) so the alert carries ATT&CK context out of the box.
Going Live: the Edge, the DNAT Trick, and the Safety Rail
The point of this whole exercise was to see a real attack, not a replayed one — which meant DVWA needed to be reachable from the actual internet, with its logs showing the actual attacker's IP. That's what the edge NVA and its nftables config do:
table ip nat {
chain prerouting {
type nat hook prerouting priority dstnat; policy accept;
# Publish DVWA:80 to the internet via the edge's public IP.
# No SNAT on this flow, so DVWA (and Wazuh) see the REAL client IP.
iifname "{{ ansible_default_ipv4.interface }}" tcp dport 80 dnat to {{ hostvars['dvwa'].ansible_host }}:80
}
chain postrouting {
type nat hook postrouting priority srcnat; policy accept;
ip saddr {{ targets_cidr }} oifname "{{ ansible_default_ipv4.interface }}" masquerade
}
}
The interesting decision is in the comment: the prerouting DNAT rule does not masquerade the inbound flow. Only DVWA's own egress gets source-NAT'd in postrouting. That asymmetry is the whole point — it means the real attacker's source IP survives all the way into DVWA's access log, and from there into the Wazuh alert. Without it, every alert would just say "attack from the edge box," which is useless for detection engineering.
Publishing a deliberately vulnerable app to the whole internet is also an acceptable-use problem, not just a security one — cloud providers don't love it when their IP space starts serving up known-vulnerable software to anyone who finds it. So the safety rail is a firewall rule that locks port 80 on the edge to a single operator /32:
resource "google_compute_firewall" "allow_edge_web" {
name = "${var.name_prefix}-allow-edge-web"
network = google_compute_network.vpc.id
direction = "INGRESS"
priority = 1000
source_ranges = [var.admin_source_cidr] # your own public /32 only
target_tags = ["edge-nva"]
allow {
protocol = "tcp"
ports = ["80"]
}
}
The DNAT, the missing SNAT, and the public IP are all real — this is genuine internet-facing infrastructure, not a simulation. The blast radius is just deliberately contained to one person's traffic.
The theme running through all of this
Almost every interesting decision in this lab was about preserving or containing something: preserving the real source IP through NAT so the alert means something, containing DVWA's egress so it doesn't create a routing loop, containing the public exposure so a vulnerable app doesn't become a liability. None of it is exotic — it's just paying attention to what a rule actually does at the packet level instead of what it's supposed to do.
What's Next
This is a detection pipeline, not a full SOC — the response loop isn't built yet. The roadmap for Part 2:
- Automated response. When rule
100100fires, nothing currently blocks the attacker. The open design question is Wazuh Active Response (a host- or edge-level nftables drop) versus a heavier API-driven edge block — since the edge ended up being plain Ubuntu/nftables rather than a commercial NVA, Active Response is the likely direction, but it's not implemented yet. - Case management and enrichment. TheHive + Cortex + MISP, to turn a raw Wazuh alert into a tracked case with IOC enrichment instead of a line in a dashboard.
- More attack surface. A Samba AD DC for identity attacks (Kerberoasting, password spraying), and a dedicated attacker box for repeatable, scripted attack traffic instead of manual testing.
Part 1 gets you from nothing to a working expose-and-detect pipeline with a real internet-facing target. Part 2 is about closing the loop from detection to response.