Home DockerHow to Containerize a 15-Year-Old Legacy Linux Application Without Downtime

How to Containerize a 15-Year-Old Legacy Linux Application Without Downtime

By sk
6 views 11 mins read

A 15-year-old Linux application that still runs core business logic is not a problem to be solved. It is an asset nobody wants to touch because nobody fully understands everything it does anymore.

The application itself is rarely the risk. The environment around it is. An aging OS, unsupported packages, manual configuration steps, and undocumented dependencies can turn every deployment into a small gamble.

Some organizations bring in a software partner to modernize a legacy system without replacing it to manage that risk directly. For teams that want to containerize a legacy Linux application in-house, containerization can provide a controlled starting point by packaging the application and its required runtime dependencies into an isolated, reproducible environment.

The steps below walk through the process in order: assess whether the application is a good fit, map its real dependencies, build and test a working container, and roll it into production gradually.

Docker is used as an example here, but you can also use Podman.

Confirm the Application Is a Good Candidate

Before writing anything, check the application against two lists.

Good candidates usually have:

  • Clear application boundaries
  • Reproducible dependencies (you can list what it needs, even if the list is long)
  • Standard network interfaces (TCP, HTTP, or similar)
  • Manageable storage requirements
  • No unusual hardware dependencies
  • A runtime that runs without special host privileges

Difficult candidates typically involve kernel-specific behavior, specialized hardware drivers, extreme latency sensitivity, licensing tied to physical machines, or logic fused directly to the host OS.

If your application falls mostly into the second list, containerization will cost more effort than it returns. Confirm which list describes your system before continuing.

Inventory Every Dependency

Start with a checklist: OS version, runtime, shared libraries, installed packages, environment variables, config files, filesystem paths, databases, external APIs, network ports, certificates, secrets, cron jobs, background processes, and storage locations.

Then confirm the checklist against what the application actually does, using tools that observe real behavior instead of relying on memory or old documentation.

Find every file the process touches while it runs:

strace -f -e trace=open,openat -p <PID> 2>&1 | grep -v ENOENT

This prints every file the application opens, including ones nobody wrote down. Run it during normal operation, not just at startup, since cron jobs and background tasks often touch different paths than the main process.

Find every open network connection and file handle:

lsof -p <PID>

This shows open sockets, open files, and their paths in one pass. Cross-reference it against your checklist. Anything on this list that isn't already documented is a hidden dependency.

Find what the binary is linked against:

ldd /path/to/binary

This lists the shared libraries the application requires at runtime. Any library listed as "not found" here is one you'll need to install in the container image.

strace adds noticeable overhead and can slow the process while it's attached, so limit its use to short windows on production. If you need to observe a live production system for longer, an eBPF-based tool like bpftrace captures the same syscall and file-access data with far less overhead, since it runs in the kernel instead of intercepting every call.

Four categories account for most of the surprises this step reveals:

  1. Filesystem assumptions. Reads or writes to /etc/, /opt/, /var/lib/, or /tmp/ that assume those paths always exist.
  2. Permission assumptions. Running as root, or expecting a specific UID or GID that a container's default user won't have.
  3. Network assumptions. Calls to localhost for a service that used to run on the same physical machine, or references to a fixed hostname or static IP.
  4. OS assumptions. Dependence on a specific kernel behavior, timezone, locale, or a system utility not installed by default in a minimal image.

Once strace, lsof, and ldd output stops surfacing anything new, the inventory is complete enough to start building.

Choose a Base Image

FROM ubuntu:latest is not automatically the right starting point. A base image is the starting filesystem and toolset a container builds on.

Match the base image to what you found in Step 2, not to whatever is newest:

# Check the glibc version the old server uses
ldd --version

# Check the OpenSSL version
openssl version

# Compare against what's available in a candidate base image
docker run --rm ubuntu:24.04 bash -c "ldd --version && openssl version"

If the versions are close, that base image is a reasonable starting point. If they're several major versions apart, expect the libraries your ldd check found to behave differently, and plan to test that specifically once the container runs.

Ubuntu 26.04 LTS is the current release, but it is not automatically the right choice for a legacy migration. For a 15-year-old application, ubuntu:24.04 is usually the safer starting point. It's still a fully supported LTS release. You can move to 26.04 later, once the container is stable, if you want the newer toolchain.

Write the First Dockerfile, and Expect It to Fail

Start with the minimum needed to run the application, based directly on your Step 2 inventory:

FROM ubuntu:24.04

# Install the runtime and OS packages identified during dependency analysis
RUN apt-get update && apt-get install -y \
libssl3 \
libpq5 \
<other-packages-from-your-inventory> \
&& rm -rf /var/lib/apt/lists/*

WORKDIR /app
COPY ./legacy-app /app/

EXPOSE 8080
CMD ["/app/legacy-app"]

Build and run it:

docker build -t legacy-app:test .
docker run --rm -p 8080:8080 legacy-app:test

At this point, expect specific, diagnosable failures rather than a clean start. A passing container status does not mean a working application, since a health check only confirms the process launched.

Common first-run failures and what they usually mean:

SymptomLikely cause
permission denied on a file writeContainer user lacks ownership; check UID/GID from Step 2
Database connection refusedThe app is using localhost for a service that used to run on the same host; inside the container, localhost refers to the container itself.
Missing file at startupA path from /etc/, /opt/, or /var/lib/ wasn't copied into the image
Library version mismatch errorBase image's library version differs from what ldd reported on the old server
Process exits immediately, no errorCheck docker logs <container-id>; often a missing environment variable

Fix one failure at a time, rebuild, and rerun. This loop, not the initial Dockerfile, is where most of the real work happens.

Move State Outside the Container

A container's filesystem is temporary by default. Anything written inside it disappears when the container is removed.

Identify what the application actually needs to persist (uploaded files, generated reports, logs, database files if it's not using an external database) and mount that specifically:

docker run -d \
-v /host/data/uploads:/app/uploads \
-v /host/data/reports:/app/reports \
-p 8080:8080 \
legacy-app:test

Do not mount the entire old server filesystem as a shortcut. Mount only the specific paths identified in Step 2 as needing persistence. Every additional mount is a path the container depends on existing correctly on the host, and each one should be a deliberate choice.

Separate Background Processes

A 15-year-old system is often, in practice, one server running the application plus a worker, cron jobs, cleanup scripts, and log rotation all at once, because nobody ever separated them.

List each background process your Step 2 inventory found, and assign it one of these:

# Example docker-compose.yml separating responsibilities
services:
app:
image: legacy-app:latest
ports:
- "8080:8080"

worker:
image: legacy-app:latest
command: ["/app/run-worker.sh"]

cron:
image: legacy-app:latest
command: ["/app/run-cron.sh"]

A common container pattern is to give each independently managed responsibility its own container. This makes lifecycle, scaling, failure, and monitoring easier to control. Bundling independently managed responsibilities into one container can recreate some of the operational coupling you were trying to remove.

Harden the Container Before Production

A container does not fix security issues by default. It isolates a process, but the process still carries whatever risks it had before.

Run as a non-root user:

RUN groupadd -r appuser && useradd -r -g appuser appuser
USER appuser

Set explicit resource limits. Without them, a container can consume more host CPU or memory than intended and affect other workloads.

docker run -d \
--memory="512m" \
--cpus="1.0" \
legacy-app:production

Scan the image for known vulnerabilities before deploying it:

docker scout cves legacy-app:production

This requires a Docker account. Run docker login first if you haven't authenticated the CLI yet. You can also use your preferred image-scanning tool to identify known vulnerabilities before deployment.

Move secrets out of the image and environment variables and into a secrets manager appropriate to your platform. Publish only the ports the application actually needs, and enforce network access through the container runtime, reverse proxy, firewall, or orchestration platform.

Validate Against the Legacy System

The legacy application is the baseline for correct behavior. The container has to match it, not just start successfully.

Test the same workflows against both systems and compare:

CheckLegacyContainer
StartupWorksConfirm
LoginWorksConfirm
Database operationsWorksConfirm
File processingWorksConfirm
External API callsWorksConfirm
Scheduled jobsWorksConfirm
Response time under loadBaselineCompare against baseline

Run a basic load comparison to catch performance regressions early:

# Example using Apache Bench against both systems
ab -n 1000 -c 10 http://legacy-server:8080/
ab -n 1000 -c 10 http://localhost:8080/

Do not move to rollout until every applicable workflow has been validated and any differences from the legacy baseline are understood.

You can also use a load-testing tool appropriate to the application's real workload and compare response times, throughput, CPU, memory, and error rates between the legacy and containerized versions.

Roll Out Gradually

Switching all production traffic from the old system to the new one in a single moment is the riskiest way to deploy this change.

Run both systems in parallel behind a reverse proxy, and shift traffic gradually:

upstream backend {
server legacy-server:8080 weight=9;
server container-app:8080 weight=1;
}

Increase the container's weight as confidence builds, and watch error rates and response times at each step.

If the application is being broken into smaller pieces over time rather than moved as one unit, the strangler fig pattern applies: a proxy in front of both systems routes each request to either the legacy application or the specific new component that has replaced that piece of functionality. This lets you migrate one capability at a time. It's useful when you're decomposing functionality gradually, not when you're moving an intact application into a container as a single step.

Before any cutover, write down what success and failure look like, what you'll monitor, how you'll switch traffic back if needed, and who has the authority to make that call.

A safe migration is not one where failure is impossible. It's one where failure has a controlled, pre-defined path back to a working state.

What Containerization Fixes, and What It Doesn't

Containerization typically improves dependency isolation, environment reproducibility, deployment consistency, portability, rollback speed, and CI/CD integration.

It does not fix bad architecture, existing technical debt, outdated business logic, poor test coverage, a monolithic design, underlying database problems, or business rules that were never documented.

Knowing this distinction prevents a common mistake: treating containerization as a substitute for architectural work it was never meant to do.

When to Containerize First, and When Not To

Containerize first when the application works and the environment is the actual problem, when dependencies can be reliably reproduced, when the workload doesn't require unusual host access, and when a full rewrite would introduce more risk than it removes.

Consider a different path when the architecture itself is the bottleneck, when the application depends on OS behavior that's no longer supported, when hardware access is essential to how it functions, when replacing it outright would simply be cheaper, or when the business requirements it was built for have fundamentally changed.

There is no universally correct answer. The right choice depends on which of these conditions actually describes your system.

Containerization Is a Step, Not a Finish Line

A working container is a foundation, not a destination. It typically becomes the base for CI/CD automation, better testing, improved observability, and further modernization work, if and when that becomes necessary.

A legacy Linux application does not need to be rewritten before it can be modernized. Containerization can be a controlled first step toward an environment that is more reproducible, more portable, and easier to maintain, without touching the business logic that production already depends on.

You May Also Like

Leave a Comment

* By using this form you agree with the storage and handling of your data by this website.

This site uses Akismet to reduce spam. Learn how your comment data is processed.

This website uses cookies to improve your experience. By using this site, we will assume that you're OK with it. Accept Read More