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.
Table of Contents
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:
- Filesystem assumptions. Reads or writes to
/etc/,/opt/,/var/lib/, or/tmp/that assume those paths always exist. - Permission assumptions. Running as root, or expecting a specific UID or GID that a container's default user won't have.
- Network assumptions. Calls to
localhostfor a service that used to run on the same physical machine, or references to a fixed hostname or static IP. - 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:
| Symptom | Likely cause |
|---|---|
permission denied on a file write | Container user lacks ownership; check UID/GID from Step 2 |
| Database connection refused | The 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 startup | A path from /etc/, /opt/, or /var/lib/ wasn't copied into the image |
| Library version mismatch error | Base image's library version differs from what ldd reported on the old server |
| Process exits immediately, no error | Check 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:
| Check | Legacy | Container |
|---|---|---|
| Startup | Works | Confirm |
| Login | Works | Confirm |
| Database operations | Works | Confirm |
| File processing | Works | Confirm |
| External API calls | Works | Confirm |
| Scheduled jobs | Works | Confirm |
| Response time under load | Baseline | Compare 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.
