Container-First Modernization — A Lightweight Playbook

What actually works when you're containerizing a legacy ETL stack without a six-month roadmap, a greenfield budget, or a team that can stop delivering.

Field note: Written in 2021, published here as a record of that period.

In 2020 I led the containerization of a legacy ETL framework at a large bank's Global Risk Technology group. The suite had been running on bare metal for years. Documentation was inconsistent. Dependencies were implicit — the kind that only reveal themselves when you try to move the application somewhere new and it fails in a way that isn't in any runbook. The goal was to containerize the suite, migrate it to Kubernetes, and introduce Apache Nifi as an orchestration layer, without a major rewrite and without stopping the existing reporting cadence.

Here's what I learned about doing this without it becoming a six-month disaster.

Containerize the application, not the infrastructure

The first instinct when you start containerizing a legacy system is to also fix everything else that's wrong with it. Resist this. The goal of containerization is portability, not correctness. If the application has a bug, the container will have the same bug — and that's fine for now. The value is that the bug is now reproducible anywhere, not just on the one server where it was originally discovered.

Separate the containerization work from the modernization work in your planning and in your commits. Containerize first, get it running in Docker, validate that behaviour is identical to the bare-metal version. Then modernize. Mixing the two makes failures ambiguous — you can't tell if the new failure is from the container or the code change.

Start with the runtime, not the build

For legacy Java applications — and most enterprise ETL is Java — the Dockerfile that works is simpler than you expect:

FROM eclipse-temurin:11-jre
WORKDIR /app
COPY target/app.jar app.jar
COPY config/ config/
ENTRYPOINT ["java", "-jar", "app.jar"]

Don't start by containerizing the build process. Get the runtime working first. Build the JAR the way you always have, copy it into the image, run it. Once the runtime is stable and the behaviour is validated, containerize the build if you need to. Most teams spend a week getting multi-stage builds right before they've confirmed the application even runs in a container.

Configuration is the hard part

Legacy applications accumulate configuration in ways that are hard to see until you try to containerize them: files in paths that are assumed to exist, environment variables set in shell profiles, JVM flags passed on the command line, credentials in flat files on the filesystem. None of these patterns work cleanly in containers.

The migration path that caused the least pain for us: map every configuration source, replace file-based config with environment variables where possible, use Kubernetes ConfigMaps for non-secret configuration and Secrets for credentials, and mount config files via volumes only for legacy apps that can't be changed. Don't try to change the application's configuration model at the same time as containerizing it — that's two changes at once with compounding failure modes.

Nifi as orchestration: what it solves and what it doesn't

We introduced Apache Nifi to replace a tangle of cron jobs and shell scripts that orchestrated the ETL runs. Nifi's value in this context is the visual flow — when a business stakeholder asks "what runs before the risk report is generated," you can show them a diagram instead of reading shell scripts aloud. That alone is worth something.

What Nifi doesn't solve: it's not a Kubernetes-native scheduler, it has its own state management that you need to think about, and building custom processors (which we had to do) requires Java and a build process that isn't trivial. If your orchestration needs are simple — "run A, then B, retry on failure" — a simpler tool like a Kubernetes CronJob or a minimal workflow engine is probably the right answer. We chose Nifi because the flows were genuinely complex and the visual representation had real stakeholder value. Don't choose it for simpler cases.

The migration sequence that worked

The sequence that caused the fewest incidents: containerize one application at a time, run the container and the bare-metal version in parallel for at least one full reporting cycle, validate outputs match, then cut over. This is slow. It's also the only way to catch the edge cases that don't show up in normal testing — the month-end run that has different volume characteristics, the leap-year date handling, the upstream data format that changes quarterly.

We cut over twelve applications over four months. Three of them had issues that only surfaced during the parallel-run period. All three would have been production incidents if we'd cut over immediately. The parallel run is not optional.

What it enabled

Once the suite was containerized and running on Kubernetes, the lift-and-shift to new hardware became a configuration change rather than a migration project. Scaling for month-end reporting peaks became a resource limit adjustment rather than a capacity request. The ETL framework that had been a single point of failure on two specific servers became something any team member could run locally for debugging. That last point alone changed how the team diagnosed production issues.