Back to Blog

How to Containerize a Legacy Application With Docker: A Step-by-Step Walkthrough

Picture this: a business-critical Java 8 web application, running on a bare-metal server that is three major OS versions out of date. Every time the team needs to spin up a second instance for load testing, someone spends two days hunting down the exact Tomcat version, fighting OpenJDK packaging quirks, and manually recreating a pile of environment variables from institutional memory. When that server eventually gets decommissioned, the application almost goes with it. Containerizing legacy applications with Docker is exactly the kind of work that prevents that scenario — and when done incrementally, it is far less frightening than it sounds.

What "Legacy" Actually Means in This Context

Before touching a Dockerfile, define the problem precisely. A legacy application usually shares some combination of these traits: built before twelve-factor app principles existed, stores runtime state on the local filesystem, assumes a fixed hostname or IP, depends on system-level libraries installed by hand, or has configuration baked into source rather than injected at runtime. None of these disqualify it from containerization — but each needs a deliberate strategy. The goal is not to make the application cloud-native overnight; it is to encapsulate it so it runs consistently and can move between environments without tribal knowledge.

Step 1: Audit Before You Touch Anything

Start with a thorough audit. Run the application on its current host while capturing everything it touches. Tools like strace on Linux, or simply careful reading of startup logs, reveal the full picture. Document:

  • The exact runtime version (JDK 8u202, Python 3.6, Node 10 — be specific)
  • All listening ports and any outbound connections
  • Files the application reads or writes at runtime (config files, temp directories, upload folders)
  • Environment variables or registry entries it consumes
  • External services: databases, LDAP, SMTP, third-party APIs
  • Any cron jobs or background processes that run alongside the main process

This audit becomes the blueprint. Everything on that list needs an answer in your Docker setup before you can claim the container is equivalent to the original deployment.

Step 2: Write a Minimal, Honest Dockerfile

The first Dockerfile should reproduce the application's known working state as faithfully as possible — not optimize it. Optimization comes later. Start with the exact base image that matches your runtime:

For a Java 8 application, eclipse-temurin:8u392-jdk is a good starting point. For Python 3.6, you will likely need to build from python:3.6-slim since it reached end-of-life, but the image still exists. Pin the version explicitly — FROM python:3.6.15-slim-buster — so a re-build six months from now does not silently pull a different base.

A practical structure for the build stage:

  1. Set the base image with a pinned tag
  2. Install system-level OS packages the application needs (libpq-dev, libmagic, etc.)
  3. Copy only dependency manifests first (pom.xml, requirements.txt, package.json) and install dependencies before copying source — this keeps the dependency layer cacheable
  4. Copy application source
  5. Set the working directory, expose the port, define the CMD or ENTRYPOINT

Resist the temptation to use RUN apt-get install -y * with wildcards or to install the entire build toolchain in the runtime image. Use multi-stage builds to keep the final image lean.

Step 3: Handling Configuration and Secrets

Legacy applications are notorious for hard-coded configuration. The Docker-compatible approach is to externalize everything via environment variables injected at container startup. For a first pass, create a .env file (never committed to version control) and pass it with --env-file to docker run. For secrets specifically — database passwords, API keys — the right long-term solution is Docker Secrets (in Swarm) or a secrets manager like HashiCorp Vault or AWS Secrets Manager. Environment variable injection at deploy time is an acceptable intermediate step while you get the container working.

If the legacy app reads from a config file at a fixed path (/etc/myapp/config.ini), the cleanest solution is a volume mount. Alternatively, an entrypoint shell script can template the config from environment variables before the main process starts — a pattern that works well for applications you cannot modify.

Step 4: Persistent Storage and Volume Strategy

This is where teams most often get burned. If the application writes uploads, logs, or session data to the local filesystem and the container is replaced, that data disappears. The fix is named Docker volumes or bind mounts:

Storage Need Recommended Approach Notes
User uploads / media files Named volume or S3-compatible object store Volumes are simpler; S3 is better for multi-container deployments
Application logs Write to stdout/stderr; collect with a log driver Avoids disk fill; compatible with Loki, CloudWatch, ELK
Database files Separate DB container with named volume, or managed DB service Never store DB files inside the app container
Temporary/ephemeral files tmpfs mount or in-container /tmp Acceptable to lose on restart
Session state Externalize to Redis or a database Required if you ever run more than one replica

Step 5: Networking — Making Services Find Each Other

Legacy apps frequently assumed everything lived on the same machine. In Docker, each container gets its own network namespace. The solution is a Docker Compose file with a user-defined bridge network, where services reach each other by service name: your application container reaches the database at db:5432 rather than localhost:5432. Find anywhere the application connects to 127.0.0.1 and replace it with the Compose service name. For applications you cannot recompile, environment variable substitution in an entrypoint script handles this. If the legacy app expects a specific hostname (some old Spring configs do), set the hostname field in the Compose service definition to match.

Step 6: Incremental Migration, Not a Big Bang

The safest approach to containerizing a running production system is incremental. Start by running the containerized application in parallel with the bare-metal version, with a load balancer sending a small percentage of traffic to the container. Monitor error rates and response times. Only cut over fully when you have a week of clean data.

A practical sequence that works well for running production systems:

  1. Containerize and validate in staging with anonymized production data
  2. Run integration and smoke tests against the container
  3. Deploy alongside the original, using weighted routing to send a small percentage of traffic to the container
  4. Monitor for two to four weeks before full cutover
  5. Decommission the bare-metal deployment only after two clean weeks

This approach catches surprises — and there are always surprises — without downtime risk. Mexilet Technologies follows this exact sequence when containerizing client systems.

Common Pitfalls and How to Avoid Them

  • Running as root inside the container: Add a non-root user in your Dockerfile and switch to it before the CMD. Many legacy apps work fine this way; those that don't reveal a fixable permission assumption.
  • Using the :latest tag anywhere: Pin every image. Build reproducibility depends on it.
  • Ignoring the .dockerignore file: Without it, COPY . . sends your entire project directory including node_modules, .git, and local config into the build context, making builds slow and potentially leaking secrets.
  • One process per container is a principle, not a law: For legacy apps with tightly coupled sidecar processes, a process supervisor like s6 or supervisord inside the container is a pragmatic compromise — just make sure health checks cover the critical process.
  • Forgetting health checks: Docker's HEALTHCHECK instruction, combined with a simple HTTP probe or TCP check, lets orchestrators know when a container is actually ready versus just running.

Frequently Asked Questions

Can I containerize an application I don't have the source code for?

Yes, with caveats. If you have the compiled artifact (a JAR, a WAR, a compiled binary), you can wrap it in a container. Configuration injection via environment variables and entrypoint scripts still works. The main limitation is you cannot change how the application discovers its config or connects to services — so you will need to work around its assumptions rather than fixing them at source. This is common with commercial off-the-shelf software being moved to containers.

How long does it typically take to containerize a legacy application?

A straightforward web application with a database backend typically takes one to two weeks, including testing and staging validation. Applications with complex networking (RMI, CORBA, legacy messaging), hard-coded file paths throughout the codebase, or tight coupling to host system libraries can take four to eight weeks. The audit phase in Step 1 is the key determinant — teams that rush past it consistently pay more time and money later.

Should I use Docker Compose or Kubernetes for running legacy containers in production?

Docker Compose is the right starting point for single-server deployments or small workloads. Kubernetes becomes the better choice when you need multi-node scheduling, automatic pod restarts, rolling deployments, or horizontal scaling. Starting with Compose and migrating to Kubernetes once you understand the container's production behavior is a lower-risk path than jumping straight to Kubernetes.

What is the biggest security risk when containerizing legacy applications?

Running with excessive privileges. Legacy apps often ran as root on bare metal because it was the path of least resistance. Inside a container, that root maps to a highly privileged user on the host kernel. Add a dedicated non-root user in the Dockerfile, never expose the Docker socket to containers unless strictly required, and scan images with a tool like Trivy to catch known CVEs before they reach production.

When you're ready to build this, Mexilet can help — explore our cloud & DevOps services and software engineering team.

If your team is staring down a legacy modernization project and unsure where to start, a focused technical scoping call can save weeks of wrong turns. Reach out to Mexilet Technologies to map out your containerization roadmap — we will review your current architecture, identify the highest-risk migration steps, and give you a realistic timeline and approach before any code is written.