Closing the Door Does Not Hide the Drawers
The deploy port was closed, yet /.git/ stayed readable through the staging hostname. Next: an nginx edge regex guard and an api/db network split.
TL;DR
Closing the inbound port didn't protect a staging server still serving .git and .env files over its tunnel. The fix needs three layers: nginx edge rules denying dot-paths, server_tokens off, and explicit Docker networks isolating services. Each layer covers what the others miss; any one alone leaves the same exposed drawers.
That morning, after the inbound port on the deploy target was closed, a simple probe was run against the staging hostname with curl. A standard error page or a connection timeout was expected, a sign the network was sealed. Instead, requests to /.git/ and /.env came back with their contents and a 200 OK status. Closing the main door from the internet did not automatically hide the open drawers inside the system.
The First Assumption That Missed
The assumption was that the outbound-only tunnel configuration was enough. The logic seemed simple: with no surface exposed directly to the internet, the edge and internal network layers needed no further work. The surface was considered safe and the hardening session was considered done.
In reality, the tunnel terminated at the nginx edge running on the same machine as the application. Development paths stayed readable through the staging hostname because no blocking rule existed. Further, Docker Compose's default pattern places every service on a single network for the entire project [3], which means every service can talk to every other service purely by sharing the same project declaration [2]. Closing the deploy port turned out to be only the first layer.
The Guard at the Edge Layer
The first fix starts at the edge. The ngx_http_access_module supports deny and allow rules in server or location contexts [1], and the rules are checked in sequence until the first match is found, at which point evaluation stops [1]. A location can be a prefix or a regular expression, with the ~ modifier for case-sensitive and ~* for case-insensitive matching; the pattern is matched against the normalized URI, where %XX characters have already been decoded and . or .. references already resolved before matching runs [4]. Encoded traversal attempts are blocked automatically, because the pattern is caught at the normalization layer:
location ~ /\.(?!well-known) {
deny all;
}
One official note for growing rule lists: when the deny rules pile up, nginx recommends using variables from the geo module to keep the configuration readable [1]. The /.well-known/ exception was kept on purpose so ACME challenges for Let's Encrypt certificates still get answered. Version disclosure is cleaned up with server_tokens off: nginx stops emitting its version in the Server header and on error pages, which is displayed by default [4].
Docker Network Isolation
The second layer limits who may talk to whom. Docker's default bridge gives containers unrestricted access to each other [2]. The safer pattern is a user-defined network per service group: API and database share only an internal network, separated from the web network [2]. A small detail that is easy to miss: on the default bridge, containers cannot refer to each other by name [2], which historically forced people to configure static IPs between containers, a habit that makes isolation audits harder. User-defined networks solve both problems at once: service names come back to life through built-in DNS, and reachability can be scoped per group [2]. Compose does create one default network per project with service-name DNS [3], so separate networks must be declared explicitly for lateral communication to die at the start. Two extreme drivers are worth remembering as guardrails: the host driver removes isolation between the container and the host, and none isolates a container completely from the host and other containers [2]. The verification stays short and repeatable: send requests to dot-path-sensitive locations and confirm they are rejected at the edge with a 403 before reaching the application, then enter the database container and confirm no route to the web network exists from there.
A hardening session is not complete because one layer looks tidy. A correct tunnel means no unexpected inbound door. A strict edge guard means no open configuration drawers. A proper network split means no lateral path for a compromised container. Each closes the gap the others leave behind; two out of three still leaves the same story as the morning probe.
Sources
[1] nginx Docs, Module ngx_http_access_module
[2] Docker Docs, Networking overview
[3] Docker Docs, Networking in Compose
[4] nginx Docs, Module ngx_http_core_module