DEV Community

Nancythedev
Nancythedev

Posted on

Docker Compose merges your ports, it doesn't replace them

I vendor a third-party compose file rather than hand-editing it, because upstream changes it
between releases and edits turn every refresh into a manual merge. My own changes go in an
override file.

Upstream published a service like this:

services:
  app:
    ports:
      - "8080:80"
Enter fullscreen mode Exit fullscreen mode

That binds every interface. I wanted it on loopback only, so the override said:

services:
  app:
    ports:
      - "127.0.0.1:8080:80"
Enter fullscreen mode Exit fullscreen mode

Reads like a fix. It is not one.

Compose merges sequences, it doesn't replace them

The effective configuration was both entries:

ports:
  - mode: ingress
    target: 80
    published: "8080"        # upstream's, still every interface
  - mode: ingress
    host_ip: 127.0.0.1       # mine
    target: 80
    published: "8080"
Enter fullscreen mode Exit fullscreen mode

My override did not override anything. It appended. The file read as a security fix and the
service still answered every interface.

I only found out because the two bindings collided on the same port and the container refused to
start. Pick a different host port, or have upstream pick one, and it merges cleanly and ships. The
bug would have passed review, because the override file says 127.0.0.1 right there in it.

The fix

services:
  app:
    ports: !override
      - "127.0.0.1:8080:80"
Enter fullscreen mode Exit fullscreen mode

!override replaces the parent's list instead of extending it. There is also !reset to drop an
inherited value entirely.

The part I changed my mind about

I wrote a test afterwards that grepped the compose file for 127.0.0.1. Then I deleted it,
because it checks a declaration — which is the same class of mistake one level up. The
declaration was already correct and already wrong.

What replaced it opens a socket:

def test_it_is_not_reachable_from_the_network():
    assert can_connect("127.0.0.1", PORT)  # it is actually up
    for address in this_machines_own_addresses():
        assert not can_connect(address, PORT)  # and only there
Enter fullscreen mode Exit fullscreen mode

The first assertion carries as much weight as the second. Without it a stopped service makes the
test pass for the wrong reason, which is how a security test quietly stops testing anything.

One caveat worth knowing on Windows: a machine connecting to its own address does not traverse the
host firewall. So a test like this proves the binding and says nothing about firewall rules.
Those have to be read separately.

Which is where I found the same shape a second time. Firewall rules are a permissive union — if
any enabled allow-rule matches, the traffic is permitted. A narrowly scoped rule sitting beside a
broad one for the same program is not a narrow rule. It is a broad rule with a reassuring name.

Two controls, same failure, found a week apart: the thing that looks like the fix is not always
the thing doing the work. Check the behaviour, not the declaration.

Top comments (0)