Pakkit.net
← Back to blog

Infrastructure

Containerizing a Legacy App Is a Networking Problem

Lifting an old application into a container is rarely a Dockerfile problem — it's a fight over the app's assumptions about its own network identity and lifecycle, and default container networking breaks exactly the things a legacy app quietly depends on.

  • Infrastructure
  • Docker
  • Networking
  • Legacy Systems

I assessed moving a crusty legacy application off its aging VM and into a container, and went in expecting the hard part to be the Dockerfile — pick a base image, copy the app in, set the entrypoint. That part took an afternoon. The hard part, the part that actually decides whether the thing works, was networking and identity: the app had strong, unspoken assumptions about where it lives on the network and who it is, and default container networking cheerfully breaks every one of them. Containerizing a legacy app isn’t a packaging exercise. It’s a negotiation with the assumptions the app baked in years ago.

The app assumes a stable network identity

Modern apps are written to be relocatable — they don’t care what IP or MAC they have. Legacy apps frequently do care, deeply, in ways nobody documented:

  • Identity-locked licensing. The one I looked at had a license cryptographically bound to a network interface’s MAC address. A container gets a different MAC by default, so the license would read as invalid and the app would refuse to run. The whole migration hinged on reproducing that one identifier inside the container.
  • A fixed address other things point at. Clients were configured to reach the app at a specific address. Change it — as containers love to do — and those clients silently stop connecting, even though the app is up.
  • Inbound connections. The app doesn’t just make outbound calls; other systems connect to it. Default bridged/NAT container networking mangles inbound reachability and rewrites source addresses, and this app keyed on the source identity of its callers.
  • A dynamic port range. It allocated ports across a wide range for sessions, which is awkward to express and awful to NAT.

A cloud-native app treats its address as disposable. A legacy app treats its address as part of who it is. You can’t containerize the second kind without preserving its identity.

Default container networking is the wrong tool here

The usual container networking — a bridge with NAT — is built for stateless services that make outbound calls and receive traffic through a mapped port. It’s exactly wrong for an app that needs a stable MAC, a fixed IP, unmangled inbound connections, and a wide port range. Fighting it with port mappings and hairpin rules is a losing battle.

The move that actually works is to give the container a real presence on the network rather than hiding it behind NAT — macvlan networking, where the container gets its own MAC and its own IP directly on the network segment, pinned to the values the app expects (including that licensed MAC). Now the app is, as far as the network and the license are concerned, exactly where and who it always was. Host networking is the blunter fallback. Either way, the insight is that the container has to inherit the app’s network identity, not impose a new one.

Lifecycle is the other half

The second cluster of surprises is about process lifecycle, and it’s just as capable of corrupting data. A legacy app often has a real shutdown sequence — flush caches to disk, close files cleanly — triggered by a signal. In a container, your process is PID 1, and PID 1 has to actually handle SIGTERM and run that graceful shutdown. Get it wrong and the orchestrator’s stop becomes an effective SIGKILL, and the app dies mid-write with a corrupt state store. So the container has to run the app in the foreground as a proper init-behaving PID 1, with a generous stop grace period, wiring the stop signal to the app’s real shutdown path.

And a subtler one: an old binary is often linked against a specific C library. Drop it on a minimal base image built around a different libc and it simply won’t run — the reflexive “just use the tiny Alpine image” is a trap for anything built against glibc. The base image is a compatibility decision, not a size contest.

The Dockerfile is the easy 10%

Add it up and the pattern is clear: the container image itself is the trivial part. The real work is preserving the app’s network identity (MAC, IP, inbound reachability, ports), matching its lifecycle expectations (PID 1, graceful shutdown), and picking a compatible runtime base. Those are the assumptions the app shipped with, and a container doesn’t erase them — it just changes which ones you have to satisfy by hand.

This is why I treat any legacy containerization as a risk assessment, not a lift-and-shift: you’re not moving the app, you’re re-satisfying its hidden contract with its environment in a new place. And it’s worth being honest that the move is a hosting change, not a fix — the app is still old, and if the vendor’s gone the risk is still yours, just in a nicer wrapper. Same lesson as trying to front a stubborn app with a proxy: the tool in front rarely talks the app out of what it already assumes. If you’ve wrestled a legacy app into a container and the fight was all networking, I’d love to compare war stories.