Every network segment in this setup defaults to deny for traffic crossing into another segment. Nothing routes from one VLAN to another unless there’s an explicit rule naming the source, the destination, and the port. That’s a normal recommendation (default deny is the textbook answer for how segments should treat each other) and I’m not going to pretend I discovered something new by doing it. What I want to write down honestly is what it actually costs day to day, because the “textbook answer” framing tends to skip that part, and I’ve now lived with the choice long enough to know where it bites.
What you get
The upside is genuinely simple and I don’t want to undersell it before getting to the complaints: nothing crosses a trust boundary silently. If a service on one segment starts trying to talk to something on another segment — because I added a new integration, because a container got a new environment variable pointing somewhere it shouldn’t, because something is doing reconnaissance after a compromise — it just doesn’t work. Not “it works until someone notices,” not “it works because a wildcard rule from three years ago happens to cover it.” It fails, immediately, visibly, until a rule exists that says otherwise. That’s one layer in a bigger reachability model — see Six Layers of Reachability for how a firewall rule fits alongside routing, DNS, and tunnel existence as one of six independent things that all have to clear before a request gets anywhere.
That property compounds over time in a way that’s easy to undervalue. A network built on default-allow (or on rules broad enough to be functionally default-allow) accumulates invisible paths: connections that happen to work because nothing’s blocking them, not because anyone decided they should exist. Nobody remembers why half of them are possible. Default deny inverts that: every path that exists, exists because someone put it there on purpose. When I look at my firewall rules, I’m looking at an actual map of intended trust relationships, not an archaeological dig through what happened to still be open.
What it actually costs
Here’s the part I want to be honest about, because a lot of writing on this topic stops at “it’s more secure” and treats the trade as self-evidently worth it without describing the trade itself.
Every new integration is a deliberate, visible interruption to the thing you’re trying to do. Say I stand up a new service on one segment and it needs to talk to a database that lives on another. In a flat or default-allow network, that’s zero extra steps: the connection either works or it’s a code bug. Here, it’s a firewall change: figure out which segment the new service landed on, figure out which segment the database is on, add a rule for that specific source, destination, and port, apply it, and only then does the thing I was building start working. That’s not a huge amount of work in isolation, but it’s a real tax on momentum. When I’m in the middle of standing something up and hit a connection refused, the honest reaction in the moment is mild annoyance, not gratitude for the security model.
It’s easy to forget the rule exists until something breaks. The failure mode isn’t “I don’t know how to add a rule.” It’s that adding the rule is a separate step from the step I was thinking about, so it’s exactly the kind of thing that falls out of working memory. I’ve lost time more than once to a service that “isn’t working” for no apparent reason, chased logs and configs for a while, and eventually remembered that the real problem was a segment boundary I’d set up myself and then forgotten to cross. Default deny doesn’t just cost time when you add the rule: it costs time again later when you forget you needed to.
There’s a real pull toward over-broadening a rule just to make it work. This is the one I think matters most, because it’s where the security property can quietly erode from the inside. When something isn’t connecting and I’m trying to get it working, the fast path is: allow the whole segment instead of the one host, allow a range of ports instead of the one the service needs, allow it bidirectionally instead of one-way. Every one of those shortcuts makes the immediate problem go away faster than figuring out the precise rule. And every one of those shortcuts is exactly the kind of scope creep that turns a deliberately narrow allow-list into something that’s default-deny in name only. I’ve caught myself doing this: writing a rule wider than the actual need because I wanted the thing working now and told myself I’d tighten it later. Sometimes I do go back and tighten it. Sometimes the wider rule is still there today because nothing has broken as a result of it being wide, which is exactly the kind of silent, comfortable drift default deny is supposed to prevent.
It doesn’t scale for free with the number of segments. The rule count grows faster than the segment count as more things need to talk to more other things. Two segments is one possible rule. Five segments where most pairs need some legitimate traffic is a real matrix to keep straight, and each new segment I add is a source of quadratic-ish bookkeeping, not linear. At the scale I’m running this — a single homelab, one person maintaining it — that’s manageable. It’s a cost that would get sharper, not smaller, if this ever grew past a scale where one person can hold the whole rule set in their head.
Why I keep it anyway
None of that is a reason to abandon default deny; it’s a reason to be honest that “more secure” and “more friction” are the same decision, not two separate ones where you get to keep one and skip the other. The friction is the mechanism, not a side effect of it. The reason a new integration can’t “just work by accident” is the exact same reason it takes a deliberate step to make it work on purpose. You cannot have “nothing crosses silently” without also having “everything requires an explicit act,” and the explicit act is, definitionally, more annoying in the moment than not having to think about it.
What I’ve settled into is trying to make the deliberate step itself as cheap and as narrow as I can, rather than trying to avoid paying it. When I add a rule, I write down what it’s for in whatever change tracking I’m using, so future-me chasing a “why does this work” question has an answer instead of an archaeology project. I try to default to the narrowest version of a rule I can get working, and treat “I’ll tighten it later” as a debt I actually go back and pay rather than a permanent excuse. Neither of those fully closes the gap between the ideal (every rule perfectly scoped, always) and the reality (a person under mild time pressure occasionally taking the wider shortcut). But the alternative, dropping the default-deny posture because maintaining it correctly is real work, trades a known, bounded cost for an unknown, unbounded one. I’d rather keep paying the toll I can see.
What actually pushed me toward keeping it
The moment that made this stick wasn’t a security incident: I haven’t had one that this policy specifically stopped, and I’d be fabricating a war story if I claimed otherwise. It was smaller and more mundane: at one point I went looking for why a background job on one segment could quietly reach a service on another segment that had nothing to do with it, and the honest answer was that the rule permitting it had been written months earlier for a completely different, one-off reason, left in place after that reason stopped applying, and nobody (meaning me, at a different point in time) had gone back to remove it. That rule wasn’t doing anything malicious. It was just wider than it needed to be, sitting there as a latent path that had outlived its justification. In a flatter network that kind of leftover path is invisible, because there’s no equivalent of “a rule that shouldn’t still be there”: there’s just connectivity, full stop, with no record of whether anyone meant for it to exist. Default deny at least makes the leftover visible as a specific line in a specific ruleset, something I can look at, question, and remove, instead of an assumption baked silently into “how the network happens to behave.”
That’s a smaller win than “this stopped an attacker,” but it’s the more honest one. What default deny mostly buys me isn’t dramatic prevention of a hypothetical breach; it’s a permanent, inspectable record of every intentional crossing between segments, which turns “why can this reach that” from an open-ended investigation into reading a rule and its comment. The friction described above is real and I pay it regularly. The payoff is just as real, and it shows up less as a single dramatic save and more as every future audit being a rule list instead of a guess.
A periodic pass instead of a one-time setup
Because the over-broadening pressure is constant and not a one-time risk, I’ve started treating the rule set itself as something to revisit on a schedule rather than something I get right once and leave alone. Every so often I go through the existing allow-list and ask, for each rule, whether it’s still scoped as narrowly as it could be and whether the integration it exists for is still something I actually run. Rules for services I’ve since decommissioned are the easiest kind of drift to miss, because nothing breaks when a stale rule just sits there unused — there’s no error to notice, no expired-permission prompt, just a slightly wider attack surface than the current, actual set of integrations requires. That pass isn’t glamorous work and it’s tempting to skip in favor of anything that feels more like forward progress, but it’s the closest thing I have to a check against the specific failure mode described above: a rule outliving the reason it was written, quietly, because nothing forced anyone to look at it again.