Running a 2009 app in 2026: legacy workloads in microVMs
Somewhere in your estate is a machine everybody is slightly afraid of. It serves the billing portal, or the endpoint the warehouse scanners have talked to since before the scanners were replaced. It runs PHP 5.6, or a Java 6 WAR in a Tomcat nobody has restarted deliberately, or a Python 2.7 daemon, or a vendor binary linked against a glibc from an operating system that reached end of life while you were at a different company. Its build recipe existed in the head of one employee, who left in 2017. Now "modernise the legacy app" is on a roadmap, and the first suggestion in the room was: just containerise it.
I'm Ajay; I build PandaStack, a Firecracker microVM platform, and this is one of the two or three shapes people most reliably arrive with. The reason containerising a genuinely old application goes badly is not effort, or Dockerfile skill, or that old code is inherently cursed. It is one structural fact that no amount of iteration will move: a container image pins the userland and never pins the kernel. If the thing that is old about your app is the kernel it expects, a container cannot help you.
A container image pins userland. It never pins the kernel.
A container is a polite suggestion to the host kernel about what a process ought to be able to see. Namespaces change the view, cgroups change the budget, seccomp narrows the menu — but the kernel making every one of those decisions is the host's, at the host's version, with the host's modules and boot parameters. Your image can hold a userland from 2011. It will still be issuing syscalls to a kernel released last month. For modern software that is invisible. For genuinely old software it shows up as a repeatable set of failures:
- The legacy vsyscall page. Binaries built against very old glibc reach for the fixed-address vsyscall page, and whether touching it is emulated or fatal is decided by the host kernel's `vsyscall=` command line, which increasingly defaults to `none`. Your container gets a SIGSEGV; the fix is rebooting the host that runs everything else you own.
- Syscalls that came and went. `sysctl(2)` was removed outright. Others changed semantics, gained flags, or began returning `EPERM` under configurations that did not exist when the app was written. The image cannot carry the syscall surface — the kernel is the syscall surface.
- Seccomp defaults that move underneath you. Runtimes ship a default profile tuned for contemporary software. Old code calls something now blocked by default and gets `EPERM` from a syscall that has worked since 1998, producing some of the least searchable error messages in the industry.
- `personality(2)` is a costume, not a time machine. `setarch --uname-2.6` makes `uname` lie convincingly enough to get an installer past a version check. It does not restore the behaviour behind that number, and "installer passes, runtime misbehaves" is worse than a clean failure.
- Kernel modules. You cannot `insmod` from a container in any arrangement you would run in production. An app needing an out-of-tree module, an old netfilter target, or a removed filesystem driver needs a kernel that has it — which should be that kernel's problem, not the fleet's.
- Sysctls that are not namespaced. Most of `vm.*` and much of `kernel.*` are per-host. An app that wants `vm.overcommit_memory=1` or a shared-memory limit from another decade is asking you to retune every workload on the node for the one you trust least.
- cgroup v1 to v2. Old runtimes and old JVMs read the v1 layout to size heaps and thread pools. On a v2 host they find something else, guess wrong, and either over-allocate until the OOM killer arrives or under-allocate and crawl. Host property, not image property.
Each of those fixes is a kernel boot parameter, a module, a sysctl, a kernel version, or a seccomp policy — and in a container all five belong to the host, which is to say to every other workload on it. Containerising the legacy app does not isolate it from your fleet's kernel decisions. It enrols it in them.
# The image is from 2014. The kernel is whatever the host booted this
# morning, and there is no flag on `docker run` that changes it.
$ docker run --rm centos:6 uname -r
6.12.9-200.el9.x86_64 # not 2.6.32, and never was going to be
# Old statically linked binaries reach for the legacy vsyscall page.
# Whether it exists is decided by the HOST's kernel command line:
$ grep -o 'vsyscall=[a-z]*' /proc/cmdline
vsyscall=none # -> SIGSEGV, in every container on this box
# The remedy is rebooting the host with vsyscall=emulate, i.e. changing
# the memory-safety posture of a machine that also runs everything else,
# on behalf of the workload you trust least. This is usually where the
# migration ticket acquires its first six-week estimate.
# Inside a microVM: its own kernel, its own cmdline, its own modules.
$ uname -r
5.10.223
$ grep -o 'vsyscall=[a-z]*' /proc/cmdline
vsyscall=emulate # a per-workload decision, not a fleet-wide oneThe real driver is blast radius, not compatibility
Compatibility gets the migration ticket written. Security is what should be driving it. Your legacy app is, by construction, software nobody is patching. PHP 5.6 stopped receiving security fixes at the end of 2018; Python 2.7 ended in 2020; Java 6 is a museum piece. Whatever that vendor binary links against has a decade of published advisories filed against it — each one a permanently indexed description of how to compromise your app, with a proof of concept attached.
So the honest planning assumption is not "if." It is "eventually, and probably without us noticing on the day." Which makes the interesting question: when someone does get code execution inside your legacy app, what is adjacent to them?
In a container, what is adjacent is the host kernel — the same kernel enforcing the isolation of every other container on that node. You have taken the workload with the highest probability of compromise and given it the shortest path to your control plane, your service account tokens, and your instance metadata endpoint.
You are not making the legacy app safe. You cannot. The app is what it is and nobody is shipping patches for it. What you are choosing is where the fire is allowed to burn.
In a microVM, what is adjacent is a guest kernel that exists only for this workload. The attacker who owns your PHP owns a kernel governing one application, one filesystem, one network namespace. Their next move is not a local privilege escalation against a shared kernel; it is an escape from the VMM, across a hardware virtualization boundary, through a device model that in Firecracker's case is deliberately tiny — a handful of virtio devices, no emulated legacy hardware — with the VMM itself jailed behind its own seccomp filter. That is a categorically harder problem, and its solution does not automatically deliver the rest of your estate.
Standing the old thing up, and proving it works
The mechanics are less dramatic than the architecture discussion. You need a rootfs — usually derived from a disk image of the original box, because the build recipe is gone — and a guest kernel you chose on purpose. From there it is create, write, exec, health-check, with the host fetching nothing on the guest's behalf.
from pandastack import Sandbox
import time
# In practice `template` is your own baked template: the rootfs you
# rescued off the old box, plus a guest kernel you picked deliberately.
# `base` stands in here so the shape stays copy-pasteable.
sbx = Sandbox.create(
template="base",
ttl_seconds=86400,
metadata={"app": "billing-legacy", "owner": "platform", "era": "2009"},
)
# Ship the app in over the filesystem API. The host clones nothing from
# the internet, and the guest gets no credentials with which to fetch it.
with open("billing-php56.tar.gz", "rb") as f:
sbx.filesystem.write("/opt/billing.tar.gz", f.read())
sbx.filesystem.write("/opt/start.sh", "\n".join([
"#!/bin/sh",
"set -eu",
"cd /opt/billing",
"exec ./bin/httpd -f conf/httpd.conf -DFOREGROUND",
]))
for cmd in [
"tar -xzf /opt/billing.tar.gz -C /opt",
"chmod +x /opt/start.sh",
"setsid /opt/start.sh > /var/log/billing.log 2>&1 < /dev/null &",
]:
r = sbx.exec(cmd, timeout_seconds=180)
if r.exit_code != 0:
raise SystemExit("setup step failed: " + cmd + "\n" + r.stderr)
def healthy():
r = sbx.exec(
"curl -fsS -o /dev/null -w '%{http_code}' http://127.0.0.1:8080/status.php",
timeout_seconds=10,
)
return r.exit_code == 0 and r.stdout.strip() == "200"
ok = False
deadline = time.time() + 90 # old software boots at old-software speeds
while time.time() < deadline:
if healthy():
ok = True
break
time.sleep(2)
if not ok:
# The logs are inside the guest, which is the only place they were
# ever going to be. Pull them before the machine goes away.
print(sbx.exec("tail -n 80 /var/log/billing.log").stdout)
sbx.kill()
raise SystemExit("legacy app never came up")
print("healthy:", sbx.id)On PandaStack every create restores a pre-baked Firecracker snapshot rather than cold-booting — there is no warm pool of idle VMs — so the machine is there in about 179ms at p50, 203ms at p99. The first-ever boot of a new template costs around 3 seconds, which for a template you bake once is a rounding error in a project measured in quarters.
"We can never rebuild this box" becomes "we have a bit-exact snapshot"
This is the part that converts sceptics. What makes legacy modernisation frightening is rarely the code — it is that the running machine is the only authoritative artifact. Somebody applied a hotfix in 2013 by editing a file over FTP. Somebody else installed an extension from a source that no longer resolves. A cron job writes into a directory whose permissions were fixed by hand. No build reproduces this, and every attempt produces a subtly different machine that fails a subtly different way three days later in production.
A microVM turns that machine into a file. Once it is healthy, snapshot it: guest memory and disk state captured together. The unrebuildable box stops being an argument about institutional memory and becomes an artifact with a checksum — one you can restore, archive, and fork, so the migration team gets a disposable copy of the real thing instead of a staging environment that has never resembled production.
import json
# Freeze a machine that is, right now, demonstrably working.
snap = sbx.snapshot()
print("frozen working state:", snap)
# Write down what the snapshot does not explain about itself. In three
# years the person restoring it will not be you, and the failure mode of
# an undocumented snapshot is identical to the failure mode of an
# undocumented server -- which is the problem you just solved.
manifest = {
"snapshot": snap,
"template": "billing-php56-v3",
"guest_kernel": "5.10.223",
"kernel_cmdline_notes": "vsyscall=emulate required by vendor binary",
"rootfs_provenance": "dd image of prod-billing-01, 2026-08-14, sha256 9f2c...",
"known_unpatched": "PHP 5.6.40 (EOL 2018-12-31); Apache 2.2.x",
"egress_allowlist": ["db-billing.internal:5432", "gw.payments.example:443"],
"data_owner": "finance-platform@example.com",
}
sbx.filesystem.write("/opt/PROVENANCE.json", json.dumps(manifest, indent=2))
# A disposable copy of the exact working machine, for the migration team
# to break without going anywhere near the one serving traffic.
# Same-host fork lands in 400-750ms; cross-host, 1.2-3.5s.
scratch = sbx.fork()
print(scratch.exec("php -r 'echo PHP_VERSION;'").stdout)
scratch.exec("php migrate_invoices.php --dry-run=0", timeout_seconds=900)
scratch.kill()The fork is worth dwelling on. "Can we test the invoice migration against real data?" has historically meant a restore into staging, a week of scheduling, and a result everyone quietly distrusts. A fork of the live machine is the live machine, memory state included, in under a second. Run the destructive thing, inspect what it did, throw the copy away, do it again with a fix. That rhythm is most of what legacy teams are missing.
Egress: the legacy app should not be able to find your estate
The classic legacy incident is not the initial compromise. It is what the compromised box could reach. Old applications live on flat internal networks, hold long-lived credentials in plaintext config files, and sit inside a perimeter drawn in 2011. Moving the app into a microVM and then attaching it to that same flat network preserves the interesting half of the problem.
- Deny by default, allow by name. The app needs its database and at most one or two upstream integrations. Your admin subnets, registry, CI, and other services should be unreachable rather than merely unrouted-by-convention.
- Block the cloud metadata endpoint explicitly. The link-local address is the highest-value target for anything with network access, and no PHP monolith from 2009 has a legitimate reason to know it exists.
- No outbound internet unless something concrete needs it. Old software with unrestricted egress is how a webshell becomes a persistent implant with an update channel.
- Log the denials and alert on them. A legacy app suddenly resolving an unfamiliar domain is the highest-signal event in the whole system, and you only get it if something was there to say no.
- Inbound only through your proxy: one stable URL, TLS terminated at the edge on a modern stack, the guest's own listener never exposed. Its TLS implementation is as old as everything else about it.
- Isolate in the plumbing, not the policy. Each PandaStack sandbox gets its own network namespace and its own /30 from a pool of 16,384 pre-allocated subnets per agent, so "this VM cannot see that VM" is structural rather than a rule someone maintains.
The strangler fig: run the past and the present at once
Big-bang rewrites of business-critical legacy systems fail at a rate that would be scandalous in any other engineering discipline. The pattern that works is Martin Fowler's strangler fig: put a routing layer in front of the old system, then move functionality out from behind it one piece at a time until the old system serves nothing and can be switched off without ceremony. The hard part has always been step one — getting the legacy system behind a stable, boring URL you control, without touching the legacy system.
That is what the microVM is for. It is the container-shaped hole you put the past in while you refactor the present.
- Rescue the box into a rootfs, boot it in a microVM with a kernel you chose, health-check it, snapshot the moment it works.
- Put it behind your proxy at a stable internal URL, every route pointing at the legacy backend. Nothing has changed functionally; you have just made the front door yours.
- Instrument at the proxy. You now have per-route traffic, latency, and error data for a system that has never been observable — which tells you what is worth carving off and what is dead code nobody has called since 2019.
- Pick one route — high value, low coupling, ideally read-only — reimplement it as a normal modern service, and flip that single path. Both systems are live at once and share the same database, which is the point.
- Rehearse each flip against a fork of the production VM before touching traffic. This is where the fork stops being a nice feature and becomes the reason the plan is credible.
- Repeat. When the last route has moved, the legacy VM serves nothing. Turn it off and keep the snapshot; it is now your audit trail.
The economics help more than people expect. A legacy app serving forty requests a day was, on the old estate, an always-on server sized for a load profile from a previous decade. Per-second billing on active vCPU-hours ($0.054) and GiB-hours ($0.0162) means a low-traffic legacy workload stops being a fixed cost you justify every budget cycle, and an archived snapshot of a decommissioned system is not a running machine at all.
Four options, honestly compared
- Container — Kernel control: none; you get the host's kernel version, syscall surface, seccomp defaults, sysctls, modules, and cgroup version, and inherit every change to them. Isolation: namespaces and cgroups over a shared kernel, one kernel bug from the host. Boot: milliseconds. Effort: lowest of the four, until the day the reason your app is legacy lives below libc — at which point it is unbounded.
- Full VM (EC2, vSphere, a rack) — Kernel control: total; own kernel, cmdline, modules, sysctls. Isolation: hardware virtualization, the same boundary a microVM uses. Boot: tens of seconds to minutes, with a full device model and firmware behind it. Effort: moderate, but you have adopted a pet — an OS to patch, an image to maintain, an instance billed whether or not anyone loads the page, and a snapshot story measured in gigabytes and minutes.
- microVM — Kernel control: total, per workload; this app's kernel decisions stop being fleet-wide decisions. Isolation: hardware virtualization with a deliberately minimal device model and a jailed VMM. Boot: snapshot restore ~179ms p50 / ~203ms p99 on PandaStack, ~3s for a new template's first cold boot. Effort: moderate — produce a rootfs, own a guest kernel — and in exchange the whole machine becomes a file you can snapshot, fork in under a second, and archive.
- Rewrite it — Kernel control: not applicable; you deleted the problem rather than isolating it. Isolation: whatever the new stack provides. Boot: not applicable. Effort: an order of magnitude above the others, with the worst risk profile, because the specification for the old system is its behaviour in production and the only complete copy of that specification is the running machine. Correct as a destination; not a plan for this quarter, and not a substitute for containing the thing meanwhile.
What a microVM does not fix
This approach is genuinely useful and also routinely oversold. The honest limits:
- It patches nothing. Your app is still unpatched software with public advisories against it. Isolation changes consequence, not likelihood, and anyone filing it as remediation in a risk register is buying an uncomfortable conversation later.
- You now own a guest kernel. That is the point, and also a responsibility. If the app forces you onto an old kernel, that kernel has its own unpatched CVEs — write it down. Where you have a choice, run a current guest kernel and reach backwards only for the specific behaviour the app requires.
- Snapshots contain secrets. A memory snapshot holds whatever the application had in memory: database passwords, session keys, decrypted customer records. Encrypt snapshot artifacts at rest, restrict who can restore them, and audit restores like database access.
- Data at rest and credentials are unchanged problems. The config file with the plaintext password is still a config file with a plaintext password. Rotate what you can, scope what you cannot, and give the app credentials that reach exactly one database.
- Compliance scope shrinks, it does not vanish. Cardholder or health data in that app is still in scope. Isolation is a strong scope-reduction argument to an assessor, not an exemption — have that conversation with them rather than with your architecture diagram.
- Some boxes cannot leave. Licences keyed to a MAC address, a USB dongle, an HSM, a GPU with drivers from a vendor who stopped answering email: physical-to-virtual has failure modes no platform engineering resolves. Find out early, because it changes the plan entirely.
- A snapshot is not a backup. It freezes one moment of one machine; the database behind the app still needs its own tested restore story.
The microVM does not modernise anything. It buys the one thing a modernisation project never has enough of: time — spent somewhere the old thing keeps running exactly as it always has, on the kernel it expects, on a network where it can see almost nothing, as a file you can restore if the building burns down. And on the day it finally is exploited, the fire is confined to one guest kernel and one snapshot's worth of state, which is a sentence you can say in a post-incident review without anyone reaching for their phone.
Frequently asked questions
Can a container run an old kernel for a legacy application?
No. Containers are namespaces and cgroups applied to processes running on the host's kernel, so every container on a machine shares one kernel version, one syscall surface, one set of loaded modules, and one kernel command line. An image can pin the userland — glibc, the interpreter, the libraries — but there is no image layer, runtime flag, or orchestrator setting that supplies a different kernel. Tools like `setarch --uname-2.6` only change the version string reported by `uname`, not the behaviour behind it, which can be worse than failing outright because installers pass and the runtime then misbehaves. If your application depends on kernel-era behaviour, you need a virtual machine of some kind, and a microVM is the cheapest one.
Does putting a legacy app in a microVM make it secure?
No, and it is important to say so plainly. The application remains unpatched software with publicly documented vulnerabilities, and the probability of it eventually being exploited is unchanged. What changes is the consequence. A compromise inside a container puts an attacker on a kernel shared with every other workload on that node, one kernel bug away from your control plane and your instance metadata endpoint. A compromise inside a microVM puts them on a kernel that governs only that application, behind a hardware virtualization boundary and a minimal device model. You are choosing the blast radius, not eliminating the fire — so pair it with narrow egress, tightly scoped credentials, and the assumption that the data in that VM is already lost.
How do I get a legacy server into a microVM when nobody knows how it was built?
You do not rebuild it, you image it. Take a block-level copy of the original disk, mount it, and extract the filesystem into an ext4 rootfs rather than trying to reconstruct the machine from a package list. Then remove the parts that were specific to the old hardware: entries in `/etc/fstab` referencing devices that no longer exist, network configuration bound to old interface names or MAC addresses, and any initramfs or bootloader components a microVM does not use, since it boots your kernel directly. Make sure virtio block and network drivers are available, boot it, and iterate against the serial console until it comes up. The moment it is healthy, snapshot it — that snapshot, not the original server, becomes the authoritative artifact from then on.
Do I have to run an ancient guest kernel for a legacy app?
Usually not, and you should not reach for one by default. Start with a current, supported guest kernel and see what actually breaks — many legacy applications are only old in userland and run fine on a modern kernel once their libraries are present. Where something does break, identify the specific requirement, because it is often satisfiable with a boot parameter such as `vsyscall=emulate`, a sysctl, or a module rather than an old kernel wholesale. If you genuinely must run an old kernel, that decision belongs in your risk register alongside the application's own unpatched status, and the isolation argument becomes more important rather than less.
How does the strangler fig pattern work with a legacy app in a microVM?
Put a reverse proxy in front of the microVM and give the legacy application one stable URL that never changes for the rest of the project. Initially every route resolves to the legacy backend, so nothing is functionally different — but you now own the front door and get per-route traffic and error data for a system that was previously unobservable. Then reimplement one route at a time as a modern service and flip that path in the routing table, with both systems live and sharing the same database. Rehearse each flip against a fork of the production VM rather than a staging environment, since a same-host fork gives you the real machine and its real state in 400-750ms. When the last route has moved, the legacy VM is serving nothing and can be turned off, with the snapshot kept as an audit artifact.
Keep reading
- microVM vs VM vs container — The full version of the four-way comparison above, with the isolation boundaries drawn out.
- Controlling network egress for untrusted code — How to build the deny-by-default allowlist a legacy app should sit behind, metadata endpoint included.
- Snapshot and fork, explained — What freezing an unrebuildable box and forking it for migration rehearsals is doing underneath.
- Migrating from Heroku to microVM hosting — The same move for an app that is merely inconvenient rather than genuinely ancient.
- Sandboxing an untrusted composer install — If the legacy stack is PHP, the dependency-install step deserves its own isolation story.
49ms p50 cold start. Fork, snapshot, and scale to zero.