Most homelab projects follow a familiar arc: you bookmark the guide, carve out a Saturday, get halfway through, hit a weird dependency error on a distro you’re not running, and end up with either a half-working setup or seventeen browser tabs you keep swearing you’ll revisit. I’ve done this with Suricata specifically at least twice before.
Today I did it differently. It was done, fully, in about four hours.
Here’s what got deployed, the six different ways it tried to go sideways, and why the experience convinced me that Claude Code represents a qualitatively different class of tool for infrastructure work.
The Goal
Two hosts:
- GW1: Rocky Linux 10.1 at
10.10.0.1, my homelab gateway/router between the ISP and everything else in the lab - monitor1: Ubuntu 24.04.3 at
10.10.0.140, a dedicated monitoring server already running Prometheus, Grafana, Loki, Wazuh, and Zabbix
The objective: deploy Suricata IDS on GW1 capturing both WAN (enp2s0) and LAN (enp1s0) interfaces, ship the event data to monitor1 for visualization, and build a Grafana dashboard with WAN/LAN bandwidth, top talkers by IP, IDS alert rate, and a geographic traffic map. Also: harden the Pi-hole web UI already running on GW1 so it wasn’t reachable from the WAN.
The Inventory Phase
Before touching anything, Claude Code spun up two parallel agents simultaneously, one SSHing into GW1 and one into monitor1, to map out what was already running. This matters for a homelab that’s been accumulating services for years: any deployment that ignores existing state creates conflicts.
The inventory surfaced something immediately. monitor1’s root disk was at 100%. Prometheus had crashed silently weeks earlier. The culprit: /var/log/suricata on monitor1 had grown to 35 GB with no rotation policy. Add 943 MB of syslog.1 and 825 MB of journal data, and the disk was completely full.
Before the actual deployment even started, Claude Code identified the root cause, cleaned up 41 GB with my confirmation, fixed logrotate (daily, maxsize 500M, rotate 7), added Prometheus TSDB retention caps (--storage.tsdb.retention.size=15GB --storage.tsdb.retention.time=90d), and restarted everything. We were working on the real goal without me ever opening a second terminal.
Architecture Pivot: When Plans Meet Reality
The original plan called for Suricata on GW1 exporting NetFlow data via softflowd to monitor1. When Claude Code ran dnf search suricata on Rocky Linux 10.1, it returned nothing. EL10 doesn’t carry Suricata in EPEL yet. And libpcap-devel, a build dependency for softflowd, wasn’t available either.
This is the moment where a ChatGPT session typically stalls. You paste the error, get a new suggestion, it fails, you paste that error, loop indefinitely. Every obstacle requires you to act as the relay between the AI’s output and the system’s actual state. You need to understand the error well enough to transcribe it accurately, maintain context across multiple rounds, and decide which suggestion to try next. For a project hitting multiple independent failure modes, the cognitive overhead stacks up fast.
Claude Code just pivoted. It deployed Suricata 8.0.4 in a Podman container with host networking, using AF_PACKET to capture both interfaces directly:
podman run --rm --name suricata --network host --cap-add NET_ADMIN --cap-add NET_RAW --cap-add SYS_NICE -v /etc/suricata:/etc/suricata:Z -v /var/log/suricata:/var/log/suricata:Z -v /var/lib/suricata:/var/lib/suricata:Z docker.io/jasonish/suricata:latest -i enp1s0 -i enp2s0 --af-packet
Then, instead of NetFlow, it recognized that Promtail was already on the stack and redesigned the pipeline entirely: Suricata writes to eve.json, Promtail ships it to Loki with event_type and proto promoted to indexed labels, Grafana queries Loki directly with stream-selector syntax.
The replacement architecture is actually better than the original. eve.json contains full flow records, DNS queries, TLS metadata, and IDS alerts in a single unified stream. NetFlow gives you IP/port/bytes. Suricata gives you everything.
The Alert Noise Problem
The first run of Suricata on GW1 generated 3,400+ IDS alerts per hour. That’s not signal, that’s a firehose, and most of it was false positives from the gateway’s edge position seeing ISP L2 frames, NIC hardware checksum offload, and mid-stream session pickup at process start.
After analyzing the alert distribution, five SIDs went into disable.conf and ten ET INFO signatures were suppressed in threshold.conf:
| SID | Signature | Root Cause |
|---|---|---|
| 2200121 | Ethertype unknown | ISP L2 encapsulation, WireGuard headers |
| 2221010 | HTTP response unmatched | Mid-stream sessions at Suricata startup |
| 2027757 | DNS query for .to TLD | Legitimate: bit.ly, t.co, etc. |
| 2200075 | UDPv4 invalid checksum | NIC hardware checksum offload false positive |
| 2221034 | HTTP unrecognized auth method | Bearer tokens, OAuth flows |
suricata-update re-ran with the disable list, suricatasc -c reload-rules hot-reloaded without a container restart. Result: 0 alerts in the next 5-minute window. From 3,400/hour to zero noise without removing any legitimate threat coverage.
The Grafana Shell Escaping Bug
The first dashboard deployment looked perfect. Until you loaded it. All six Loki-backed panels showed “No data.” The Prometheus bandwidth panels worked fine.
The cause was subtle: the dashboard was pushed to Grafana via a Python heredoc over SSH, and the LogQL queries used backtick-quoted values. But zsh on my local machine was evaluating those backticks as command substitutions before they ever reached the remote server. Every event_type=`alert` became event_type= (empty string). The dashboard was querying for events with no type at all.
Claude Code rewrote the deployment as a proper Python script with no shell interpolation, and corrected the queries to use the more efficient label-selector syntax {event_type="alert"}, since event_type had already been promoted to a Loki stream label by the Promtail pipeline stage, avoiding expensive JSON parsing at query time entirely.
Pi-hole Web UI Hardening
GW1’s Pi-hole v6.4.1 was bound to 0.0.0.0:80 and 0.0.0.0:443 by default. Two lines in /etc/pihole/pihole.toml added defense-in-depth:
port = "10.10.0.1:80o,10.10.0.1:443os" # was: "80o,443os,[::]:80o,[::]:443os"
acl = "+10.10.0.0/24,+127.0.0.1" # was: ""
Pi-hole’s web process now refuses connections from any IP not on the LAN, regardless of what the firewall does or doesn’t catch.
Going Deeper: Eight More Things Worth Monitoring
With the core stack running, I asked: what else on GW1 is worth monitoring? Claude Code analyzed the setup and returned a prioritized list of eight additional monitoring opportunities with effort/value reasoning, then deployed all of them using two parallel agents running concurrently on GW1 and Grafana.
Pi-hole DNS Analytics: Rather than install a third-party exporter requiring Pi-hole v6 API authentication, the solution queried the Pi-hole FTL SQLite database directly (/etc/pihole/pihole-FTL.db) as root, exporting to node_exporter’s textfile collector via a Python cron script. No auth, no exporter binary, live data immediately.
First results: 374,000 DNS queries in the last 24 hours. Block rate: 8.7%. Top blocked domain: stats.grafana.org with 7,482 hits. Your monitoring stack is the noisiest DNS client on your own network, and Pi-hole is correctly silencing it.
Conntrack active connections: node_exporter already scraped node_nf_conntrack_entries, just needed a Grafana panel. Current value: 7,572 active connections out of a 262,144 limit. A sustained spike indicates a SYN flood or port scan.
WAN interface error/drop rate: node_network_receive_errs_total and node_network_receive_drop_total for enp2s0, a direct ISP line quality signal, already scraped, just unvisualized.
TLS SNI visibility: Suricata logs event_type: tls records containing the Server Name Indication field from every TLS handshake. A Grafana table of the top 20 external SNI values gives a complete picture of outbound encrypted traffic destinations, without decrypting anything.
Per-host LAN bandwidth: Suricata flow events include flow.bytes_toserver per connection. A LogQL unwrap query aggregates bytes by src_ip across the dashboard time range, providing per-device bandwidth breakdown with no nftables changes and no additional agents.
Suricata engine health: Stats events in eve.json report cumulative kernel packet drop counts. A rate panel on stats_capture_kernel_drops surfaces AF_PACKET ring buffer pressure. If the drop rate climbs, Suricata is missing traffic and detection coverage has a gap.
The dashboard went from 7 panels to 26.
Claude Code vs. ChatGPT: An Honest Comparison
I want to be precise about this because “AI is better for X” claims are often fuzzy.
ChatGPT and browser-based Claude are collaborative editors. You describe a goal, they suggest an approach, you implement it, something fails, you describe the failure, they suggest again. Every system state lives in your head or in a paste buffer. The AI has no direct access to what your server actually contains. It is working from your description of it, which is always lossy. This works for isolated tasks. It breaks down for multi-host infrastructure projects with interconnected state and multiple independent failure modes, because the relay overhead dominates.
Claude Code is an autonomous agent with direct system access. When monitor1’s disk was full, it didn’t tell me to run df -h and paste the output. It ran df -h itself, identified the largest consumers, proposed a cleanup plan, and executed it on confirmation. When Suricata wasn’t available for EL10, it didn’t ask what to do; it evaluated the constraints and produced a better architecture.
The parallel agent dispatch compounds this. When GW1 and monitor1 needed different things simultaneously, they got them simultaneously. Infrastructure work has natural parallelism: two independent hosts can be configured concurrently, and Claude Code’s architecture exploits it in a way that a single chat session simply cannot.
There’s also a documentation side effect worth naming: the session produced a complete RUNBOOK.md with architecture diagrams, service locations, credential table, and operational commands, plus a timestamped audit_log.ndjson covering every change made to both hosts. That doesn’t happen naturally from a ChatGPT session.
The gap isn’t intelligence. ChatGPT is a capable model. The gap is agency, feedback loop, and parallelism.
What’s Running Now
| Host | Services |
|---|---|
| GW1 (10.10.0.1, Rocky Linux 10.1) | Suricata 8.0.4 (Podman), Promtail 3.4.2, node_exporter 1.9.1, Pi-hole 6.4.1, firewalld |
| monitor1 (10.10.0.140, Ubuntu 24.04.3) | Prometheus 3.5.0, Grafana 12.3.2, Loki 3.4.2, Wazuh+ELK, Zabbix 7.0 |
Dashboard: 26 panels live. One remaining item: the Geomap panel needs MaxMind GeoLite2 configured in grafana.ini to resolve IPs to coordinates, a 30-minute task for another day.
One afternoon. Two hosts. Zero false positive IDS alerts. Twenty-six Grafana panels. And a top blocked DNS domain that turned out to be Grafana itself.