One of the most dangerous things about Docker is how easy it is to just make another container. All I need is a Compose file, a published port, and a few seconds later I’ve got another service running.
I had repeated that seemingly harmless routine until my Home Ops server, with only 2GB of RAM, hosted seven containers and two Nginx pages. Behind that tidy little collection sat nine web ports, a lot of automatically generated bridge networks, a temporary DHCP address, and too many IP-and-port combinations to remember.
A 10th container would have still worked perfectly, but it would also have made the underlying mess worse. So I stopped deploying new things and cleaned up the network foundation first.
Related
I started using Podman instead of Docker Desktop and it’s way faster than I imagined
I replaced Docker Desktop and discovered silence is a performance metric.
Audit what Docker is already exposing
The dashboard was organized, but the host underneath it was anything but
One look at my Homarr dashboard and anyone would think my Home Ops server was super organized. Nine applications sat neatly inside one dashboard, yet each tile depended heavily on one raw address:
http://192.168.24.211:75757
The first useful audit was just a formatted container list:
docker ps –format “table {{.Names}}\t{{.Ports}}”
Its output revealed nine published application ports. A few entries in this output listed 0.0.0.0:7575->7575/tcp and similar, which meant Docker had published instances (in this case, Homarr) through every IPv4 interface on the host. The matching [::]:7575->7575/tcp covered IPv6. By contrast, a plain entry like 61209/tcp was exposed to Docker’s internal networking, but without host-side mapping.
I checked the live sockets separately:
sudo ss -lntp4 | awk ‘NR == 1 || $4 !~ /^127\./’
That confirmed that docker-proxy was actively listening on ports including 3001, 7575, 8080, 8081, 8082, and 9000.
Ports were only one part of the entire audit and the road to fixing up the mess. Docker has also generated five /16 bridge subnets, while my OpenWRT router still had the server relying on a temporary IP address from DHCP. None of these choices is a disaster by itself, but the mess came from every small experiment that quietly made another permanent network decision without me mitigating or planning it.
OS
Linux, Windows, macOS
Individual pricing
Free (Personal)
Docker is a platform that packages software into lightweight, isolated units called containers. It bundles application code, libraries, and dependencies together, ensuring software runs identically on any machine. By sharing the host operating system, Docker eliminates compatibility issues, speeds up deployment times, and maximizes server efficiency compared to traditional virtual machines.
Reserve the Docker host’s address first
Every container URL depended on an address the router was free to change
The containers themselves can restart perfectly but appear broken if their host received a different LAN address. Every bookmark, dashboard tile, and monitoring target would still be pointing at the old address.
I finally fixed this at the router level rather than inside Ubuntu. In OpenWRT, it was as simple as reserving the IP the Home Ops server was already using:
192.168.24.211
OpenWRT was therefore responsible for its address allocation, and its lease table clearly showed which machine owned the address, according to its MAC address. I also avoided using an arbitrary static address that would conflict with the router’s dynamic pool. Now Home Ops still requests its configuration through normal DHCP, but OpenWRT always gives it the same IP.
Verifying it was as easy as rebooting and watching it retain the .211 address. That reservation also made the reverse-proxy configuration I planned to do later a lot more dependable.
Give services local DNS names
Remembering a server address is more manageable than remembering nine ports
The first improvement that actually helped with productivity was replacing the server address and services with names I could actually remember:
- homarr.home.arpa
- portainer.home.arpa
- uptime.home.arpa
- files.home.arpa
Use home.arpa for local-home network records. Earlier use of .home, whichI have always used, was deprecated because queries frequently leaked into the public DNS system and conflicted with existing uses. Avoid .local as well, as it belongs to multicast DNS.
I added nine records in total using OpenWRT, all pointing to 192.168.24.211. DNS identifies the destination server, which matters because later I’ll use a reverse proxy that inspects these records to decide which container receives the connection.
From a Windows machine, I verified the first record:
Resolve-DnsName homarr.home.arpa
It returned the reserved server address. I also configured Docker to use OpenWRT at 192.168.24.254 for DNS, which allowed containers to resolve the same local records when needed.
DNS alone was a first step, as it didn’t remove the necessity to use port numbers. At this point, I’d only progressed to accessing services like Homarr with:
http://homarr.home.arpa:7575
That made it easier to remember, but I still needed to know which service belonged to which port.
Stop Docker from claiming enormous automatic subnets
Five small projects had quietly consumed five separate /16 ranges
As I kept adding containers, Docker automatically created five bridge ranges:
- 172.17.0.0/16
- 172.18.0.0/16
- 172.19.0.0/16
- 172.20.0.0/16
- 172.21.0.0/16
I wasn’t close to exhausting private IPv4 space, but the allocations were excessive for projects spread between one and six containers. My bigger concern was overlap. An automatic Docker range can absolutely collide with a LAN, VPN tunnel, or routed remote network and end up sending traffic toward the wrong interface.
My existing ranges were well clear of the LAN at 192.168.24.0/24, local WireGuard at 10.14.0.0/24, and Tailscale’s 100.64.0.0/10. Still, I wanted future allocations to be deliberate and tracked, so I added this to Docker’s daemon.json:
“default-address-pools”: [
{
“base”: “172.30.0.0/16”,
“size”: 24
}
]
The size value told Docker to divide that base range into /24 networks. After restarting Docker, its built-in bridge received 172.30.0.0/24, while a temporary proof network got a configured 172.30.1.0/24. Existing Compose networks kept their original /16 ranges. Docker applied the new pool only to networks created afterward, rather than unexpectedly breaking running applications.
Sharing one Compose file did not mean every service needed to see every other service
My services_default network contains Dozzle, Uptime Kuma, Portainer, IT-Tools, and Glances. They all shared a bridge because I put them in one convenient Compose file out of laziness, not because they depended on one another.
That trust boundary was quite awkward because Dozzle and Glances could inspect the Docker socket, and Portainer could control it. Uptime Kuma, File Browser, and IT-Tools really had no reason to communicate with those more privileged containers directly.
I assigned each standalone service an explicit network:
- dozzle-net
- portainer-net
- filebrowser-net
- glances-net
The other applications all received their own matching networks. Docker gave each a /24 from my new address pool, meaning containers could now communicate only when they shared a network.
Still, this was only one layer. Separate Docker networks don’t replace application authentication or firewall rules. The original LAN ports were still published at this point. Nonetheless, after the configuration change, all services retained their configuration and remained reachable from my Windows machine.
Publish one controlled entry point instead of nine ports
A reverse proxy finally brought everything together
After the cleanup, I installed the lightweight Linux tool Caddy as my first new container. Instead of publishing another application port, I connected Caddy to each service’s separate network, then gave each application a hostname-based route:
http://homarr.home.arpa {
reverse_proxy homarr:7575
}
I repeated this premise for all nine services. Caddy became their shared entry without needing to put the applications back together on a common network. Caddy’s Compose file only consisted of HTTP, bound specifically to the server’s reserved LAN address:
ports:
– “192.168.24.211:80:80”
The next job was to remove the ports: block from every application. For communication inside Docker, their internal ports still existed, but Docker didn’t need to map them onto the host.
The final tests proved the improvement was worth it. All nine clean hostnames returned HTTP 200, apart from Uptime Kuma’s normal 302 redirect. Requests to every former raw port with curl returned code 000, which meant an HTTP connection was no longer possible. Docker ps showed Caddy as the only container with a host-side mapping, and Homarr was finally able to open successfully at http://homarr.home.arpa without ever needing to remember its port again.
The potentially troublesome issue with this setup is that Caddy is now another dependency, and a particularly trusted one at that, as it joins every application network. I’ve also limited what future containers can run on my Home Ops server in that any services that need discovery protocols, UDP, or a directly published port won’t be protected by the new setup.
So, installing another container was never really the difficult part. Knowing exactly where it would live and what it could reach were the network basics I was skipping. After implementing these changes, I have a much better foundation to keep building new servers with Docker for my homelab.
Any future containers will need an intentional name, network, and a reason to be reachable, rather than automatically receiving access.
Related
Docker looks complicated until you learn these 7 terminal commands
Docker can look intimidating at first, but these seven commands demystify the platform and help with troubleshooting.
