Blog

Which Clients Actually Need Their Own VM? A Tiered Docker Isolation Plan

VM for every client is overkill. Here's how to decide how much isolation each tenant needs—and automate the decision.

Summary

Agencies often panic when a client asks how truly isolated their data is from other tenants. Docker's namespaces and cgroups give you real isolation, but they are not the same as a hardware boundary. Rather than running every client on a VM—or worse, treating every client the same—build a small set of isolation tiers and match each client to one by data sensitivity, trust, and compliance. A locked-down container (non-root, capabilities dropped, seccomp, read-only root) covers most sites; regulated or hostile workloads get a VM or a container-in-VM hybrid. This post gives a repeatable decision flow, a comparison table, and an honest look at when more isolation is overkill.

Are you at the point in a sales call where the new client says “we're healthcare, show me our data is isolated from your other clients” and you’d rather talk about anything else?

This is the agency problem: not one perfect deployment, but the same dependable deployment repeated across a dozen clients with different budgets, risk profiles, and compliance demands. Here's the honest version. Docker isolation is real, but it's specific. Namespaces give each container its own view of processes, networking, and filesystem; cgroups cap CPU, memory, and disk I/O so tenants can't starve each other. What that does not buy you is a hardware wall between the container and the host kernel. If an attacker escapes the container, they are inside the only kernel you have. The rest of this article turns that uncomfortable fact into a repeatable decision: classify each client by data sensitivity and trust, apply a baseline hardening profile, and only reach for a VM when the cost of a breach is higher than the cost of the VM.

Wait, aren't containers already isolated?

Docker runs on Linux namespaces and cgroups, and those words are doing real work. Namespaces separate process IDs, network stacks, mount points, and users so a process in one container can't see the process table of another. Cgroups set limits: give a container 0.5 CPU, 512 MB of memory, and a fixed block I/O weight, and that's exactly what it gets. A runaway loop in one tenant gets throttled instead of taking down the neighbor. If you haven't configured limits, you've skipped the most basic thing cgroups are for.

Take a simple PHP app in container A. It sees its own filesystem, its own network interface, its own PID 1. Container B has the same, but a different view. That's namespaces. Now walk away and skip the memory limit: container A can fill the host's RAM and make container B crawl. That's what cgroups exist to prevent. But two containers can be isolated from each other by namespaces and still share the host kernel, which is the part every container-escape story is about. An exploit that reaches the kernel can potentially reach every tenant on that host.

“Docker is isolated” is a half-true sentence. The accurate version is “Docker isolates with namespaces and cgroups, and a kernel vulnerability is the blast radius.” Before you trust a tenant to run untrusted code, sit with that thought for a minute. The answer is not “never use containers”—that’s the easy panic. The answer is a tier system.

So why do some clients need more than namespaces?

The honest answer is that isolation isn't a switch, it's a spectrum. At one end you have a fully shared container where everyone is effectively in one app. At the other end you have a separate VM per tenant with its own kernel. Most agency work lives in the uncomfortable middle, and the middle is not a binary choice between “Docker is fine” and “run a VM for everyone.”

What pushes a client to the right is not their size. It’s four questions:

  • Do they store regulated data? Health records, payment card details, anything a regulator would call sensitive.
  • Does a breach on their tenant have a realistic path to another tenant? If they can run arbitrary code, yes.
  • Do you trust the code and the people who deploy it? A client who hires the cheapest freelancer is not the same trust level as a client whose dev team you know.
  • Does their contract say “dedicated,” “isolated,” or “private”? If it does, you've already promised a tier; the only job now is picking the right one.

If you can't answer those questions yet, put the client in a baseline tier and write down the assumptions. That's not a security audit; it's a sanity check you repeat on every onboarding.

How do I decide per client without running a security audit every time?

Make a small table and commit to it. You don't need a matrix with forty cells. Four tiers will cover nearly every client an agency sees.

Client positionWhat actually separates themUse when
Tier 1: Shared app/containerApplication logic onlyInternal utilities, low-risk data, projects where everyone is explicitly in one login system
Tier 2: Same host, separate containersNamespaces and cgroupsMost marketing sites, contact forms, no sensitive data
Tier 3: Locked-down containerTier 2 + non-root, dropped capabilities, seccomp, read-only root, network segmentationE-commerce, PII, custom code you don't fully trust
Tier 4: Per-tenant VMHypervisor and a separate kernelHealthcare, finance, compliance paperwork, untrusted code, noisy neighbors

Here's how that plays out in practice. A bakery client with a contact form and an Instagram link goes to Tier 2: one container on a shared host, default Docker networking, resource limits, job done. An online store that stores customer names, addresses, and payment redirects goes to Tier 3: same shared host, but the container runs as a non-root user, has no extra kernel capabilities, uses a seccomp profile, and only exposes port 443. A medical intake portal that stores protected health information goes to Tier 4: a VM per tenant, because the cost of a breach is not “we'll clean it up” but “we can't show the client we took them seriously.”

The whole trick is that you're not rethinking the architecture for every client. You're picking a row from a table you already agreed on. That's how a five-person agency can run a hundred sites without a hundred separate security obsessions. It also means the next client doesn't get an answer that depends on which team member answered the phone. For the deeper architecture debate behind those choices, this guide on designing multi-tenant isolation levels covers the trade-offs in more detail.

What does a locked-down container actually look like?

Let's stop saying “locked down” and get concrete. This is what Tier 3 means for a typical WordPress or PHP client.

First, change the user. Most official images still run as root by default; in your Dockerfile, create a non-root user and run the app as that user. That immediately removes the most common way a container compromise becomes a host compromise. Second, drop the capabilities you don't need. Run with --cap-drop ALL and add back only one, usually NET_BIND_SERVICE so the app can listen on port 80. That alone is a bigger change than most people expect. Third, make the root filesystem read-only with --read-only, and mount writable directories (uploads, the database data directory) as volumes or tmpfs. Fourth, apply a seccomp profile and, if your host supports it, AppArmor or SELinux. Finally, put the container on a dedicated Docker network and expose only the ports that actually need to be reachable.

Let's walk through a WordPress example. The base image probably runs as root, so you add a useradd step and a USER directive. You run the container with a memory limit and a CPU limit, so a burst of plugin traffic doesn't hurt the neighbor. You mount /var/www/html/wp-content/uploads as a writable volume. You set --read-only. You attach it to a network that has no --privileged flag anywhere near it. The result is a container that used to be “a WordPress site” and is now “a WordPress site that happens to be more locked down than most virtual private servers.”

If hand-rolling all of that feels fragile, there is an easier middle path: Docker's Enhanced Container Isolation, which uses user namespace isolation and a secure container runtime. It's a legitimate shortcut, but it's not a free pass to skip non-root or capability dropping. The tenant still needs a sensible image. The difference is that the kernel-facing attack surface gets smaller without you becoming a seccomp expert overnight. If you want the exact sequence for a single tenant, the step-by-step isolation hardening guide turns this section into copy-paste commands.

When do I stop layering and just hand them a VM?

Here's the contrarian part: more isolation is not automatically better. VMs give you hardware-level isolation, a separate kernel, and a much smaller attack surface if the guest kernel falls. That is exactly what healthcare and finance clients expect when they say “we want to be isolated.” But every VM adds patching, backup, and compute cost, and it multiplies the work of keeping a fleet updated. If you VM every client because one client once told you Docker scared them, you've bought safety theater with real money.

A VM is the right answer when the risk per tenant is higher than the operational cost of a VM per tenant. That means regulated data, written compliance requirements, untrusted third-party code, or a client who needs a noisy neighbor removed. It's also the right answer when the client's contract literally promises a dedicated environment, because “container” is not what they're picturing when they sign “dedicated.”

But a VM does not excuse a sloppy container. A common trap is to put the client in a VM and then skip the hardening because “the VM protects them.” The VM protects the host from the tenant, not the tenant from its own bad image. You still want non-root, dropped capabilities, and seccomp inside that VM. The hybrid approach—containers inside a VM—is often the sweet spot: the VM provides the boundary for compliance conversations, and the container gives you the deployment workflow you already know. There's a longer version of that debate in Should Every Tenant Get Its Own VM?, but the short answer is that the VM is for the contract, not for the fear.

How do I make this repeatable across every client?

You make it repeatable by making the tier system a template, not a memory. Keep a directory of Compose files, one per tier: tier2-baseline, tier3-locked, tier4-vm-hybrid. When a new client shows up, copy the template, change the environment variables, and you already know the isolation shape before you've written a line of new infrastructure.

Then write down the decision. Not a 400-page security report, but a short paragraph in the client's repository: what data they store, which tier they're on, why, and what would move them up a tier. That paragraph is worth more than a hundred firewall rules, because it's the thing you can show the next auditor or the next worried client. It also stops you from having to remember why the bakery got Tier 2 and the e-commerce store got Tier 3 after the original sales call has faded.

Automate the boring checks. Have your CI scan every client image and fail a build if it runs as root, if it has all capabilities, or if it tries to publish a port other than the ones the tier allows. None of that is exotic; it's just making sure the template isn't accidentally broken by a well-meaning developer. If you're building the surrounding hosting workflow anyway, the production-ready Docker hosting strategies piece covers the part that comes after the containers are defined.

None of this is glamorous. No blog post is going to make “tenant isolation” sound as exciting as a greenfield architecture diagram. But this is the difference between an agency that answers “how isolated are we?” with a crossed-fingers “totally” and one that can show a tier, a config, and a reason. Containers are not a magic wall. VMs are not a magic bullet. A tier system is just a decision you write down and reuse—and for an agency, repeatable is the whole game.

Sources (5)