All posts
6 min read

Docker vs Linux Users for Multi-Tenant Workspace Isolation

Separate Linux users look like a cheap way to isolate tenants until you try to enforce a CPU limit. A walkthrough of why containers win for multi-tenant development workspaces — and how to verify the limits are real.

dockerinfrastructuresecurityisolation

If you're building a platform that runs other people's code, the first design question is how to keep tenants apart. The cheapest-looking answer is the classic Unix one: give each tenant a Linux user account, set permissions carefully, and let the kernel do the rest.

It works, briefly. Then someone runs a build.

The case for Linux users

To be fair to the approach, it has real advantages:

  • Nearly zero overhead. No container runtime, no image layers, no network namespaces. A user account costs a row in /etc/passwd.
  • Simple mental model. File permissions are well understood and have been debugged for decades.
  • Fast provisioning. useradd returns instantly. Pulling and unpacking an image does not.
  • Shared package cache by default. One copy of the base toolchain for everyone.

For a small number of trusted tenants — say, a handful of internal teams — this is a defensible choice. The problems appear at the boundaries.

Where it breaks

Resource limits are the hard part

This is the one that ends the debate. Unix users control access, not consumption. Nothing about a user account stops a tenant from spawning 200 processes or eating every core on the machine.

You can reach for ulimit, but it's per-process, not per-tenant — a fork bomb sails straight through a per-process CPU cap. The real answer is cgroups, and once you're writing systemd slices and cgroup hierarchies per tenant, you are building a container runtime by hand, badly, without the parts anyone has tested.

Containers give you this as a first-class flag. A limit of two CPUs is one argument, and you can confirm the kernel is honouring it from inside the workspace:

$ cat /sys/fs/cgroup/cpu.max
200000 100000

That reads as 200,000μs of CPU time per 100,000μs period — two full cores, enforced by the kernel. Memory and PID limits work the same way. Being able to verify the limit from inside the tenant environment matters: it means the constraint is real rather than a value in your control plane's database.

The shared filesystem is a slow leak

Every tenant on a shared host can see the same /, and quite a lot of it is world-readable by default. /etc/passwd enumerates your other customers. /proc exposes their process list and command lines — which frequently contain credentials passed as arguments. Misconfigured /tmp files show up regularly in real incidents.

You can harden all of this: hidepid, restrictive umasks, private /tmp per user, careful auditing of anything world-readable. What you cannot do is stop the next package update from adding a world-readable file with something interesting in it. You are maintaining a denylist against an operating system that assumes cooperating users.

Containers invert the default. Each workspace gets its own mount namespace and sees only what you put in the image plus its own volume. There is no enumeration problem because there is nothing to enumerate.

Dependency conflicts come back

Multi-tenancy on one host means one set of system libraries. Tenant A needs Python 3.11; tenant B needs 3.13. Tenant C needs a system OpenSSL that tenant D's runtime is incompatible with.

You end up managing per-user toolchain installs, which is most of the complexity of images with none of the reproducibility. Containers make the base environment a versioned artifact — you can offer a Node image, a Python image, a PHP image, and rebuild any of them without touching the others.

Recreation is destructive

When a user-account environment gets into a bad state, "start fresh" means deleting a home directory and hoping nothing outside it mattered. There is no clean line between the environment and the project data, because they live in the same tree.

With containers, that line is the whole design. Destroy the container, keep the volume:

/opt/easyspawn/workspaces/<user-id>/<project-id>   →  mounted at /workspace

The container is rebuildable from an image; the volume holds the source, git history, dependencies, and environment. Recreating a broken workspace becomes routine instead of frightening.

Escalation surface

A local user account on a shared host is one kernel bug or one SUID misconfiguration away from being every account on that host. That risk never disappears — container escapes exist too — but containers add layers: namespaces, capability dropping, seccomp profiles, read-only root filesystems, no privileged operations.

More importantly, containers make the default restrictive and the exceptions explicit. With user accounts, the default is permissive and you spend your time discovering what you forgot to lock down.

The comparison

Concern Linux users Containers
CPU / memory limits Manual cgroups Built in, verifiable
Filesystem isolation Permissions on a shared tree Separate mount namespace
Process visibility Visible via /proc by default Separate PID namespace
Dependency conflicts One system toolchain Per-image toolchains
Clean recreation Delete home dir, hope Destroy container, keep volume
Provisioning speed Instant Seconds (image pull, then cached)
Overhead Minimal Small but real
Escalation blast radius Whole host Container, then host
Path to orchestration None Kubernetes when you need it

What you give up

Containers are not free, and pretending otherwise is how you end up surprised:

  • Provisioning is slower on a cold image pull. Warm caches and a small set of base images matter.
  • Storage multiplies. Layers plus per-tenant volumes use more disk than one shared toolchain.
  • The runtime is a dependency. You now operate a container runtime, and its bugs are your bugs.
  • Isolation is not a VM. Containers share a kernel. For genuinely hostile multi-tenancy, you eventually want microVMs — Firecracker, gVisor, Kata — underneath the containers. Containers are the right default; they are not the last word.

How to verify isolation is real

Whichever route you take, test it rather than trusting the config:

  1. Burn CPU. Run a busy loop with more threads than the limit allows and confirm from the host that the workspace is capped.
  2. Allocate past the memory limit. The process should be OOM-killed, and only that workspace should be affected.
  3. Fork bomb it. The PID limit should hold and the host should stay responsive.
  4. Try to enumerate. From inside a workspace, look for other tenants — /proc, /etc/passwd, mount points, the Docker socket. You should find nothing.
  5. Fill the disk. One tenant exhausting storage must not take down the others.
  6. Destroy and recreate. Confirm the project data survives and nothing else does.

If any of these surprise you, the isolation is aspirational.

The conclusion

Linux users answer "who can read this file." Multi-tenant platforms need to answer "how much of this machine can you take," and that is a different question with a different tool.

Containers give you enforceable limits, real filesystem isolation, per-tenant toolchains, and a clean separation between disposable runtime and durable data — plus a path to orchestration when one host stops being enough. The overhead is real and worth it.


EasySpawn runs each workspace in an isolated container with enforced CPU, memory, and process limits, as a non-root user, with project data on persistent storage. See how it works or join the waitlist.

Keep reading