all posts

Processing PHI in Per-Job microVMs: Isolation for Regulated Healthcare Data

Ajay Kumar··10 min read

You are building a health-tech product, and somewhere in it is an ingestion pipeline. It receives HL7v2 messages over MLLP from a hospital's interface engine, FHIR bundles from a modern EHR's API, CCDA documents from a health information exchange, DICOM studies from a radiology PACS, and — reliably, unavoidably, in 2026 — faxed PDFs, because a meaningful fraction of American healthcare still moves over a phone line. Then you parse them, de-identify them, OCR them, or run inference over them. The data is PHI. The parsers are C. The senders are hospitals running interface engines that were configured during the Clinton administration and have not been touched since, because the person who understood them retired.

I'm Ajay; I built PandaStack, an open-source Firecracker microVM platform, so I spend my time thinking about where the isolation boundary belongs when you run risky code against sensitive data. This post is about giving each PHI-processing job its own disposable hardware-virtualized guest: why clinical formats are genuinely hostile input even when nobody is attacking you, what a per-job VM buys you when you translate compliance concepts into engineering terms, the specific places PHI accumulates when you are not looking, why default-deny egress is the control people skip, and — the part I want you to actually take away — why a snapshot of a guest that touched PHI is itself a PHI-bearing artifact.

This is an engineering post about isolation architecture, not legal advice, and I am not your compliance officer. Compliance is a program — risk analysis, policies, training, agreements, audits, incident response — not a config flag. A microVM is a technical control. It is not a certification, and no infrastructure vendor, including mine, can make your product "HIPAA compliant" by existing. What follows describes what this architecture gives you and, just as importantly, what it does not.

The input is hostile even when nobody is attacking

The mental model that gets teams into trouble is "our senders are hospitals, so the data is trusted." Trust is the wrong axis. The question is not whether the sender means well; it is whether the bytes they send will make your parser do something surprising. And clinical interchange formats are, by construction, the kind of thing that makes parsers do surprising things — they are decades old, enormously permissive, and full of extension points that different vendors filled in differently.

HL7v2: a pipe-delimited format with per-site dialects

HL7v2 is segments separated by carriage returns, fields separated by pipes, components by carets, repetitions by tildes, and — this is the fun part — the delimiter characters themselves are declared in the first segment of each message, so the message tells your parser how to parse it. Escape sequences are their own sublanguage. Field lengths are advisory. And every site has Z-segments: locally defined, undocumented segments that a vendor added in 2004 to carry something the standard did not cover. Your parser will meet a message where a field that is supposed to hold a coded value holds a paragraph of free text with an embedded pipe that somebody forgot to escape, and what happens next is entirely a property of your parser's error handling, not of the standard.

DICOM: a container format that ends in an image decoder

DICOM is the interesting one, because people think of it as "medical images" when it is really a container format with a tag-based metadata model and a long list of registered transfer syntaxes describing how the pixel data inside is encoded. That encoding is very often not raw: it is JPEG baseline, JPEG lossless, JPEG-LS, JPEG 2000, or RLE, encapsulated in fragments inside the dataset. Which means the terminal step of parsing a study is handing attacker-influenced compressed bytes to an image decoder — historically one of the single most reliable places in all of software to find memory-corruption bugs. Add multi-frame studies, private tags full of vendor-specific binary, and the fact that a single study can be gigabytes across thousands of instances, and you have an input that is simultaneously an exploitation surface and a resource-exhaustion surface.

CCDA: XML, which means XXE, which means a breach

CCDA documents are XML, and XML brings its entire family with it: entity expansion (ten nested definitions that expand to a billion strings from a file you could send in a text message), external entity resolution, external DTD and schema fetching, and stylesheet references that CDA documents legitimately carry. XXE is the one that should keep you up at night in this specific context, because the exploitation path is short and the consequence is regulatory. A parser that resolves external entities inside a process that can reach an internal service is a file-reader and a request-forger sitting on your network, in an environment full of exactly the thing an attacker wants. An XXE in a PHI-processing worker is not a bug report. It is the first paragraph of a breach notification.

Faxed PDFs: their own universe

And then there are the faxes, which arrive as PDFs wrapping CCITT Group 4 or JBIG2 images of a page somebody printed, signed, and fed into a machine. To do anything useful you run OCR, which means you have added a document parser, another set of image codecs, and an OCR engine to your dependency graph — three more layers of native code processing bytes chosen by a fax machine in a clinic you have never heard of. I wrote about that specific surface in more depth in /blog/microvm-ai-agent-pdf-document-processing; the short version is that document parsing stacks have a CVE history you can read like a museum catalogue.

You cannot validate your way out of this class. Determining whether a document will break a parser generally requires running the parser — a schema check on a CCDA does not tell you what libxml2 will do with it, and a DICOM header scan does not tell you what the JPEG 2000 decoder will do with fragment seven. The only defense that holds is to run the parse somewhere you can afford to lose.

The compliance-shaped argument, made in engineering terms

Here is where per-job isolation stops being a security preference and starts lining up with things your compliance program already has to assert. I am not claiming a microVM satisfies any particular requirement — that is a determination for your risk analysis and your counsel. What I am claiming is that the architecture makes several assertions cheap to make truthfully, instead of expensive to make hopefully.

  • Minimum necessary access, structurally — the guest is handed one patient's payload, or one tenant's batch, and no database credential. A compromised parser cannot enumerate, because there is nothing to enumerate: no connection pool, no service account, no sibling job's temp directory. Contrast that with a shared worker whose whole job is to hold a credential that reaches every record you have.
  • "What could this process reach" becomes answerable — with a hardware-virtualized boundary per job, the answer is a short, auditable list: this document, this vCPU and RAM allocation, this network namespace with no route out. On a shared host the honest answer is "the kernel's entire syscall surface, the page cache, whatever else is mounted, and anything reachable from the pod's network identity," which is not an answer you want to write in a risk assessment.
  • PHI stops accumulating — an ephemeral guest means the record does not settle into a long-lived worker's /tmp, page cache, core dumps, crash reports, or a log file that rotates weekly. The residue problem is solved by the machine ceasing to exist rather than by a cleanup routine that has to be correct every time.
  • Attribution matches the unit of work — one job, one guest, one log stream, one resource profile. Your audit trail names a machine and a job id rather than requiring you to grep an interleaved worker log for a patient identifier. Note the recursion there: grepping logs for a patient identifier is itself the thing you were trying to avoid doing.
  • Retention gets a mechanism — a TTL on the guest is a hard upper bound on how long a copy of that document exists in your processing tier. Policies are documents; a reaper is an implementation.
  • Blast radius is priced — if the DICOM decoder gets popped, the attacker owns a throwaway machine containing one study, with no egress and a few minutes to live. That is an incident. It is not a notification event affecting every patient in the database.
The useful test is not "could this be exploited?" — everything can. It is "if this is exploited, how many patients are in the room?"

Where PHI ends up when you are not looking

This section is the one I would print out. Most PHI leaks I have seen discussed are not exploitation at all — they are copies of clinical data ending up in places nobody classified as a data store, because the mechanism that put them there is invisible from the application code.

  • Core dumps — a segfaulting decoder writes its address space to disk, and that address space contains the study it was decoding. Worse, on most modern distributions `kernel.core_pattern` pipes the core to a crash handler, which is a program specifically designed to collect and forward crash data. `ulimit -c 0` is one line and it closes a real hole.
  • Swap — anonymous memory holding a decoded CCDA can be paged out to a block device that outlives the process, the container, and possibly the reboot. If your work directory is tmpfs, remember tmpfs pages are swappable too.
  • Crash reporters and APM agents — the ones that helpfully attach request payloads, local variables, or a snippet of the offending input to an exception report and ship it to a vendor you have no agreement with. An APM tool capturing 200 characters of an HL7 message body is a disclosure, and it is enabled by a config default in a library you did not choose.
  • Log lines that echo the record — the debug statement someone added during an integration and never removed. Log "parse failed at segment 7, field 3" and not the segment. Never log the message body, not even at debug, because debug is on in staging and staging gets a copy of production data more often than anyone admits.
  • Metrics labels — a Prometheus label is a dimension, dimensions get stored forever in a time-series database with different access controls than your application database, and a well-meaning engineer will absolutely label a counter with an accession number or an MRN to make a dashboard useful.
  • Sandbox metadata — the same trap, one layer down. Put a job id in a sandbox's metadata field. Never a patient identifier, an MRN, an accession number, a name, or a date of birth. Metadata is queryable, listed in API responses, exported to your observability stack, and printed in operational logs by design.
  • De-identification that happens downstream — if the raw record leaves the guest and gets de-identified in a service on the other side, then every hop in between handled PHI and inherits every control PHI requires. The de-identification has to happen inside the boundary; the whole point is that only the de-identified artifact crosses it.

The last one deserves emphasis because it changes the shape of the pipeline. If you de-identify in the guest, the guest is the only component in the processing tier that ever sees identifiers, and everything downstream — your warehouse, your feature store, your model training job, your analytics — is out of scope for that particular concern. If you de-identify downstream, you have built a PHI pipeline and called it an analytics pipeline.

# Runs first, inside every PHI job guest. None of these lines are
# optimizations -- each one closes a place clinical data has historically
# been found later, by someone who was not looking for it.
set -euo pipefail

# 1. Core dumps. A segfaulting DICOM decoder writes the study it was
#    decoding to disk. On most distros core_pattern PIPES that image to a
#    crash handler -- a program whose entire purpose is to collect and
#    forward crash data. Turn off both halves.
ulimit -c 0
sysctl -w kernel.core_pattern='|/bin/false' >/dev/null

# 2. Swap. Anonymous memory holding a decoded CCDA can land on a block
#    device that outlives the process. A guest should have none; assert it
#    rather than assume it, because tmpfs pages are swappable too.
swapoff -a || true
sysctl -w vm.swappiness=0 >/dev/null

# 3. Work directory in RAM, not on the rootfs. noexec/nosuid because the
#    only thing in here is a document from a stranger's fax machine.
mkdir -p /work
mount -t tmpfs -o size=2g,noexec,nosuid,nodev tmpfs /work

# 4. Determinism. A de-identification run that behaves differently on a
#    Tuesday is not auditable, and locale-aware date parsing on a DOB field
#    is exactly the kind of quiet wrongness nobody catches for a year.
export LC_ALL=C.UTF-8 LANG=C.UTF-8 TZ=UTC
export PYTHONUTF8=1 PYTHONHASHSEED=0

# 5. XML external entities. Much of the healthcare parsing world is Java
#    (HAPI and the HL7 reference implementations), and these two system
#    properties disable external DTD and schema resolution process-wide.
#    There is no universal switch across every XML stack -- check yours.
export JAVA_TOOL_OPTIONS="-Djavax.xml.accessExternalDTD='' -Djavax.xml.accessExternalSchema=''"

# 6. Prove the egress policy instead of trusting it. If this SUCCEEDS, the
#    guest can reach the internet and must not be handed PHI. Fail closed
#    here, before the document is written, not after the parser has it.
if curl -s --max-time 3 -o /dev/null https://example.com; then
  echo "FATAL: egress reachable from a PHI guest -- refusing" >&2
  exit 78
fi

exec python3 -m phi.deidentify --in /work/in --out /work/out

Egress: default-deny, or you have not modeled the exfiltration path

Ask most teams how PHI would get out of their processing tier and they describe a compromised application making an outbound HTTP call. That is the modeled path. The unmodeled path is a parser doing exactly what the format told it to. An XML parser fetching an external DTD from a URL embedded in the document. A stylesheet reference in a CDA. An image decoder or a document renderer resolving a remote resource. A helpful library following a redirect. None of that is code you wrote, none of it appears in your threat model, and all of it is an outbound request originating from a process holding a patient record.

Default-deny is the only posture that survives this, because the alternative — enumerating everything a parser might fetch and blocking it — is the same losing game as enumerating everything a file might do to a parser. The guest should have no route out, and anything it legitimately needs (a terminology service lookup, a model endpoint) should be an explicit, named allowance you had to think about, ideally proxied through something you control so the request is logged and the response is bounded.

This is easier when the network boundary is per job rather than per fleet. PandaStack gives each sandbox its own Linux network namespace and TAP device, so the deny rule is scoped to that one guest instead of being a shared policy that some other workload also depends on — and revoking it is deleting the guest. I go deeper on the general pattern in /blog/controlling-network-egress-untrusted-code. The operational point is that the assertion "this job could not have sent data anywhere" should be a property of the job's own environment, verified inside the job, not a claim about a firewall rule that a platform team maintains for everyone.

Determinism and retention, mostly for free

There is a second-order benefit that has nothing to do with security and everything to do with auditability. On PandaStack, every create restores a baked Firecracker snapshot rather than cold-booting — the restore step is roughly 49ms, 179ms p50 end to end, about 203ms p99, with the ~3s cold boot happening once at bake time. The consequence is that every job starts from a byte-identical machine: same parser versions, same ICU and timezone data, same de-identification ruleset, same locale. When someone asks, eighteen months from now, what code processed a given record and in what environment, the answer is a snapshot generation identifier rather than an archaeology project involving base-image tags.

Retention comes along too. A TTL on the guest is a hard bound on how long a copy of a clinical document exists in your processing tier, enforced by a reaper rather than by a cron job someone has to keep working. The guest, its tmpfs work directory, the decoded pixel buffer, the extracted XML, and the source document all cease to exist at the same moment, which is a much easier property to explain than "we call unlink in a finally block and we are fairly confident it runs."

One snapshot-specific gotcha that bites clinical workloads hard: a restored guest wakes up believing it is bake day. If your pipeline stamps a processing timestamp, computes an age from a date of birth, applies a date-shifting de-identification rule, or checks whether a result is stale, a frozen clock produces confidently wrong output. Sync the clock on resume, and pass any date your logic depends on in as an explicit parameter. A de-identification pipeline should never ask the machine what day it is.

A snapshot of a guest that touched PHI is a PHI-bearing artifact

This is the non-obvious one, and it is the thing I would most like people to internalize, because it is a place where a security-improving feature quietly creates a new data store. A Firecracker snapshot is a memory image plus device state plus a disk. If the guest had a patient's record in memory when you snapshotted it — in a parser's buffer, in a decoded pixel array, in a Python string that has not been garbage collected, in the page cache backing the file you just read — then the snapshot file contains that record. Not a reference to it. The bytes.

Which means the snapshot needs every control the original document needed: encryption at rest, access controls, an audit trail, a retention schedule, and inclusion in your data inventory. And snapshots are exactly the kind of artifact that escapes inventories, because they feel like infrastructure rather than data. They get replicated to object storage for cross-host restore. They get kept around for debugging. They get copied to a staging environment so someone can reproduce a crash. Every one of those is a copy of a patient record moving to a place with different controls, performed by an engineer who believed they were moving a VM image.

The clean rule is to never snapshot a guest that has touched PHI. Snapshot the template — the clean, pre-loaded, data-free machine — and restore that for every job. Use fork and snapshot for the workloads where they shine (fanning out from a warm, dependency-loaded base), and treat any snapshot taken after PHI entered the guest as a regulated artifact requiring a deliberate decision, or better, as something your tooling refuses to produce. The same logic applies to hibernate: a hibernated guest is a snapshot with a friendlier name. There is a broader treatment of what ends up inside these files in /blog/firecracker-snapshot-secrets-security, and it applies to clinical data at least as forcefully as it does to credentials.

The shape of the pipeline: pure guest, trusted orchestrator

The structural discipline is the same one that fixes bulk imports and billing runs: the sandbox is pure. It receives one payload, does the risky work, and emits a de-identified artifact plus an attestation of what it did. It holds no credential, so it cannot write to your datastore, cannot call an external API, and cannot leave a half-processed record behind. The trusted orchestrator — which does hold the database handle and the key material — receives the output, checks it, commits it, and writes the audit entry.

from pandastack import Sandbox
import json

def process_phi_payload(job_id: str, payload: bytes, kind: str) -> dict:
    """De-identify ONE clinical payload in a machine built for one payload.

    Note what is deliberately absent from this guest: the EHR database DSN,
    the object-store credential, the KMS key, any other patient's record,
    and any route to the internet. A parser that gets popped in here owns a
    throwaway machine with one document in it and nowhere to send it.
    """
    sbx = Sandbox.create(
        template="code-interpreter",
        ttl_seconds=600,           # a retention control, not just a safety net
        metadata={
            # NON-PHI ONLY. Metadata is queryable, returned by list APIs,
            # exported to metrics, and printed in operational logs. A job id
            # belongs here. An MRN, a name, an accession number, or a date
            # of birth does not -- not once, not "just for debugging".
            "job": job_id,
            "kind": kind,          # hl7v2 | ccda | dicom | fhir | fax-pdf
            "class": "phi",        # tags the guest for the audit trail
        },
    )
    try:
        sbx.filesystem.write("/work/fences.sh", FENCES)   # the bash block above
        sbx.filesystem.write("/work/in", payload)

        # The fences script asserts egress-deny BEFORE the parser runs, then
        # execs the de-identifier. One command, one exit code, one verdict.
        out = sbx.exec("bash /work/fences.sh", timeout_seconds=540)

        # FAIL CLOSED. A segfault in the pixel decoder, a tripped egress
        # assertion, a timeout on a 4GB study, an unparseable Z-segment --
        # all land here and all produce zero downstream records. The
        # alternative is a partially de-identified document, which is just
        # an identified document with extra steps.
        if out.exit_code != 0:
            raise PhiJobFailed(job_id, out.exit_code, out.stderr[-2000:])

        # Only the de-identified artifact crosses the boundary. The original
        # bytes never leave the guest; they leave existence with it.
        record = json.loads(sbx.filesystem.read("/work/out/record.json"))
        attest = json.loads(sbx.filesystem.read("/work/out/attestation.json"))
        return {"record": record, "attestation": attest, "guest_id": sbx.id}
    finally:
        # The guest, the tmpfs work dir, the decoded pixel buffer, the
        # extracted XML, and the source document all stop existing together.
        # No cleanup routine to get right on the unhappy path.
        sbx.kill()

And the other side of the boundary. The orchestrator is the component that holds everything the guest was denied, which is precisely why it should never see a raw clinical document.

// The TRUSTED orchestrator. It holds what the guest never did: the database
// handle, the key material, and the audit writer. The sandbox could not
// have written a row or called an API -- not "was not supposed to", could
// not, because nothing in it had a credential or a route.

type Attestation = {
  method: "safe-harbor" | "expert-determination";
  identifiersRemoved: number;    // COUNTS, never the values themselves
  residualRiskFlags: string[];   // e.g. ["free-text-note-retained"]
  rulesetDigest: string;         // sha256 of the de-id ruleset that ran
};

async function commitDeidentified(
  jobId: string,
  guest: { id: string; template: string; snapshotGeneration: string },
  record: unknown,
  attest: Attestation,
) {
  // Fail closed a second time, on this side. A guest reporting residual
  // risk -- an unredacted free-text note, an OCR page it could not read --
  // does not get to auto-publish into the research warehouse. Automation
  // that quietly downgrades "unsure" to "fine" is how these go wrong.
  if (attest.residualRiskFlags.length > 0) {
    return quarantineForHumanReview(jobId, guest, attest);
  }

  await db.transaction(async (tx) => {
    await tx.insertDeidentifiedRecord({ jobId, record, attest });

    // The audit entry names the MACHINE, not the patient. One job, one
    // guest, one snapshot generation -- so "what code touched this, in what
    // environment, with what access" is a row lookup instead of a grep
    // through an interleaved worker log for an identifier you should not
    // have been logging in the first place.
    await tx.insertAuditEntry({
      jobId,
      guestId: guest.id,
      template: guest.template,
      snapshotGeneration: guest.snapshotGeneration,
      deidMethod: attest.method,
      rulesetDigest: attest.rulesetDigest,
      egressPolicy: "deny-all",
      at: new Date().toISOString(),
    });
  });
}

What this architecture does not give you

I want to be precise here, because vendor material in this space is usually not. Per-job microVM isolation is one technical control addressing one family of risks. It is emphatically not a compliance posture, and several genuinely important things sit entirely outside it.

  • You still need a BAA with whoever runs the hardware — and with anyone else in the path who handles PHI on your behalf. Isolation architecture does not create a business associate relationship, and running an open-source platform yourself does not remove the need for one with your cloud provider. Verify what any given vendor will and will not sign against their current terms; this moves.
  • Encryption at rest and in transit are separate concerns — a microVM boundary says nothing about whether the disk under it is encrypted, whether your snapshot bucket is, or whether the MLLP link from the hospital is doing TLS. These are independent controls you have to implement and evidence independently.
  • Key management is its own problem — where keys live, who can use them, how they rotate, and how you prove that. The orchestrator holding key material means the orchestrator is now the component under the most scrutiny, which is a deliberate trade: one hardened component instead of a fleet of workers that all hold credentials.
  • Access control, workforce training, and audit review are program work — the architecture makes the audit trail cheap to produce and clean to read. Someone still has to review it, and someone still has to decide who may query the de-identified warehouse.
  • De-identification is a methodology question, not an implementation detail — whether you are following the safe-harbor identifier list or an expert determination, and whether free text has actually been scrubbed, is a determination made by people with the relevant expertise. The guest is where the code runs; it is not where the method is decided.
  • Snapshots, backups, and logs inherit everything — as covered above, and it is worth repeating: any artifact derived from a guest that held PHI is in scope. Your data inventory has to include the ones that look like infrastructure.

Shared worker pool vs container-per-job vs microVM-per-job

  • Minimum necessary access — Shared worker pool: the worker holds a credential reaching every patient record, because that is what lets it write results; any compromise is an enumeration primitive. Container per job: better scoping is possible, but the container usually inherits a service-account identity with the same reach, and secrets are mounted by the platform. MicroVM per job: the guest gets one payload and no credential at all — it emits a de-identified artifact and the trusted orchestrator does the writing.
  • Parser exploitation blast radius — Shared worker pool: a heap overflow in an image or XML decoder yields execution inside the process holding everyone's data. Container per job: the process boundary is real but the kernel is shared, so a kernel-reachable bug in the parsing path is a host problem and a neighbour problem. MicroVM per job: a hardware-virtualized guest with its own kernel; a successful exploit owns a throwaway machine containing one document.
  • PHI residue — Shared worker pool: temp files, page cache, core dumps, swap, and log files all persist across jobs on a long-lived host, so cleanup has to be correct every single time. Container per job: the writable layer goes away, but host-level swap, core dumps routed to a host crash handler, and shared log pipelines do not. MicroVM per job: the guest's memory and disk cease to exist together; residue is bounded by the guest's lifetime rather than by a cleanup routine.
  • Egress control — Shared worker pool: policy is fleet-wide, so the allowance one workload needs is an allowance every workload has. Container per job: network policy is per-pod at best, and typically written once for a deployment rather than per job. MicroVM per job: a dedicated network namespace and TAP per sandbox means deny-by-default is scoped to that one job, and the job can assert it from the inside before touching data.
  • Auditability and attribution — Shared worker pool: logs interleave across patients and tenants, so attribution means searching by identifier, which is itself a disclosure risk. Container per job: better, though the pod is often reused and logs are aggregated by deployment. MicroVM per job: one job, one guest id, one log stream, one resource profile — the audit entry names a machine instead of a patient.
  • Retention behaviour — Shared worker pool: retention is a policy plus a cron job plus hope. Container per job: bounded by pod lifetime, which can be long if the pod is reused across jobs. MicroVM per job: a TTL is enforced by a reaper, and expiry deletes the guest and everything in it as one event.
  • Determinism — Shared worker pool: parser versions, locale, and timezone data are whatever the current base image shipped, so a rebuild can silently change how a date field is interpreted. Container per job: pinned by image digest, which is good, though the runtime environment underneath still varies. MicroVM per job: every job restores the same baked snapshot generation, byte-identical, so a re-run eighteen months later behaves the same way.
  • Cost — Shared worker pool: effectively free per job, which is why everyone starts here. Container per job: cheap, with image-pull latency on cold nodes. MicroVM per job: on PandaStack a create is 179ms p50 and about 203ms p99, because every create restores a baked snapshot (~49ms for the restore step) rather than cold-booting, and the ~3s cold boot happens once at bake time. That is a fifth of a second in front of a job that takes seconds to minutes.

That last line is what makes this practical rather than aspirational. "One VM per clinical document" sounds absurd if you are picturing a thirty-second provision; at a fifth of a second it costs less than the JSON serialization of the attestation. Capacity holds up too — each PandaStack agent pre-allocates 16,384 /30 network subnets, so concurrency is bounded by host CPU and memory rather than by network slots, which matters when a hospital's nightly batch delivers forty thousand messages between 02:00 and 02:12 because that is when their interface engine was configured to flush.

The summary

Clinical data arrives in formats designed by committees in the 1980s and 1990s, extended locally by vendors who are no longer in business, and delivered by senders who are not attacking you and whose bytes will nonetheless find the sharp edges of your parsers. Underneath every one of those formats is native code: a decompressor, an XML parser, an image decoder, an OCR engine. You cannot validate your way to safety, because deciding whether a document breaks a parser requires running the parser.

A disposable hardware-virtualized guest per job addresses the whole family in one move. The parser runs somewhere expendable. The guest holds one patient's payload and no credential, so a compromise cannot enumerate. De-identification happens inside the boundary, so only the safe artifact crosses it. Egress is denied by default and asserted from the inside before the document is written. The environment is byte-identical on every run, so the de-identification is reproducible and the audit entry can name a snapshot generation. And the TTL means the record, the temp files, and the machine all stop existing at the same moment — which is a far easier thing to explain to an auditor than a cleanup path you believe runs.

What it does not do is make you compliant. You still need agreements with whoever runs the hardware, encryption and key management as separate disciplines, access reviews, training, and a de-identification methodology decided by people qualified to decide it. And you need to treat snapshots, backups, and any other artifact derived from a guest that held PHI as PHI-bearing — the one that catches teams out, because a snapshot in a bucket looks like infrastructure right up until you remember it is a memory image of a machine that had a patient record open.

For adjacent patterns: running user-submitted pipelines against sensitive research data is in /blog/microvm-genomics-pipeline-isolation, and the same untrusted-file argument applied to bulk uploads — bombs, permissive parsers, staging instead of writing — is in /blog/microvm-per-tenant-csv-import-pipeline-isolation.

Frequently asked questions

Does running PHI processing in a microVM make my application HIPAA compliant?

No, and be suspicious of anyone who says otherwise. Compliance is a program — risk analysis, policies, workforce training, business associate agreements, access reviews, audit log review, and incident response — not a property of an execution environment. A microVM is a technical control that helps with a specific family of risks: it limits what a compromised parser can reach, it keeps one job's data out of another job's environment, it bounds how long a copy of a record exists, and it makes per-job attribution clean. Those are real and they make several assertions in your risk analysis cheap to make truthfully. But you still need a business associate agreement with whoever operates the hardware, encryption at rest and in transit, key management, access controls, and a de-identification methodology determined by people qualified to determine it. The architecture is one control among many, and none of this is legal advice — talk to your compliance counsel.

Why are HL7v2, DICOM, and CCDA considered hostile input if they come from hospitals?

Because trust in the sender is the wrong axis — the question is whether the bytes will make your parser behave unexpectedly, and these formats are old, extremely permissive, and full of vendor-specific extension points. HL7v2 declares its own delimiter characters in the first segment, has an escape sublanguage, advisory field lengths, and site-defined Z-segments nobody documented. DICOM is a container format whose pixel data is usually compressed — JPEG, JPEG-LS, JPEG 2000, or RLE — so parsing a study ends in an image decoder, historically one of the most reliable places to find memory-corruption bugs. CCDA is XML, which brings entity expansion, external entity resolution (XXE), external DTD fetching, and stylesheet references. An XXE in a process that can reach an internal service is a very short path from a parsing bug to a disclosure. None of that requires a malicious sender; it only requires a sender whose export code disagrees with yours.

Where does PHI accidentally accumulate in a processing pipeline?

In places nobody classified as a data store. Core dumps are the big one: a segfaulting decoder writes its address space, containing the record it was decoding, to disk — and on most distributions the kernel's core_pattern pipes that image to a crash handler designed to collect and forward it. Swap is next: anonymous memory holding a decoded document can be paged to a device that outlives the process, and tmpfs work directories are swappable too. Then crash reporters and APM agents that attach request payloads or local variables to exception reports and ship them to a vendor. Then debug log lines that echo the record, which are on in staging and staging often has production-shaped data. Then metrics labels, because a time-series database with different access controls is a terrible place for an MRN. And finally sandbox or job metadata fields, which are queryable, returned by list APIs, and printed in operational logs — put a job id there, never a patient identifier.

Is a Firecracker snapshot of a VM that processed PHI itself sensitive data?

Yes, and this is the point teams most often miss. A snapshot is a memory image plus device state plus a disk. If the guest had a patient record in a parser buffer, a decoded pixel array, an uncollected string, or the page cache when you snapshotted it, then those bytes are in the snapshot file. It is not a reference to the data; it is the data. So the snapshot needs the same handling as the original document: encryption at rest, access control, audit trail, retention schedule, and a place in your data inventory. That is easy to get wrong because snapshots feel like infrastructure — they get replicated to object storage for cross-host restore, kept for debugging, or copied to staging so someone can reproduce a crash, each of which moves a patient record somewhere with different controls. The clean rule is to snapshot the clean template and restore it per job, and to treat any snapshot taken after PHI entered a guest as a regulated artifact. Hibernation is a snapshot with a friendlier name.

Should de-identification happen inside the sandbox or downstream?

Inside the boundary, always. If the raw record leaves the guest and gets de-identified by a service on the other side, then every component in between — the queue, the storage bucket, the orchestrator, the transport — handled PHI and inherits every control PHI requires, and your scope expands rather than contracts. The whole architectural point of a per-job guest is that identifiers exist in exactly one place and only the de-identified artifact crosses out. Have the guest emit the scrubbed record plus an attestation describing what it did: which method it applied, counts of identifiers removed (counts, never values), a digest of the ruleset version that ran, and any residual-risk flags such as free text it could not confidently scrub. The trusted orchestrator then fails closed on those flags — routing anything uncertain to human review rather than auto-publishing it — commits the record, and writes an audit entry naming the guest id and snapshot generation instead of the patient.

Run code in a microVM in one API call.

49ms p50 cold start. Fork, snapshot, and scale to zero.

Start free
Written by Ajay Kumar, Founder, PandaStack.