Blog
The Trust Fallacy: How Our Multi-Tenant Docker Setup Leaked Data (And How We Fixed It)
Learn how one team's naive Docker setup led to a cross-tenant data leak and the layered isolation strategy that prevented it.
Summary
Docker containers are not isolated by default—they share the host kernel, and without deliberate configuration, tenants can interfere with each other. This article walks through a real scenario where a multi-tenant hosting provider discovered that client containers could access each other's databases due to shared networking and weak security defaults. We show the step-by-step changes that fixed the breach: per-tenant user-defined networks, non-root users, dropped capabilities, read-only filesystems, and seccomp profiles. A common assumption is that containers inherently provide strong isolation; we challenge that by explaining why VMs still offer a harder boundary and when to consider a hybrid approach. The conclusion reinforces that isolation is a layered exercise, not a single checkbox.
The Incident: When Containers Talk Too Much
You've set up Docker on a single host to run multiple client websites. Each client has its own container—a neat, isolated environment, right? That's what we thought. Until a routine security audit revealed that Client A's container was reading the MySQL socket of Client B's container on the same host. They shared the default bridge network. Worse, the containers ran as root, so an attacker who compromised one could tamper with the host's Docker socket or another container's filesystem. The breach wasn't a sophisticated exploit; it was basic misconfiguration. Data leaked. Trust dissolved.
The failure scenario isn't uncommon. Many teams assume Docker's namespaces and cgroups automatically wall off tenants, but they underestimate how many escape hatches remain open by default. Default bridge networks offer no network isolation between containers. Running as root gives the container more power than needed. And without explicit resource limits, one noisy neighbor can starve others of CPU or memory.
Step 1: Stop Sharing a Single Network
Our first fix was to give each tenant its own user-defined Docker network. This prevents containers from reaching each other unless you explicitly connect them. We created a script that, for each tenant, spins up a dedicated network and attaches their application container to it. The database container lives in the same tenant network, but we also added an internal network for intra-tenant communication only. No more cross-tenant prying.
We also isolated the databases by running them in separate containers on the same tenant network, using separate data volumes. This ensured that even if an attacker broke into the app container, they couldn't sniff database traffic from another tenant.
For a deeper dive into network isolation strategies, see the A Practical Docker Isolation Security Checklist for Multi-Tenant Hosting.
Step 2: Drop Unnecessary Privileges
By default, Docker containers run with a limited set of Linux capabilities, but they still have more than most applications need. Our containers ran as root, which allowed processes inside to perform actions like mounting filesystems or changing kernel parameters. We switched to running the application as a non-root user inside the container (using the USER directive in the Dockerfile) and dropped all capabilities except those absolutely required. For a typical web app, that might be only NET_BIND_SERVICE (for binding to ports under 1024) and CHOWN (for writing to directories). We also added --security-opt no-new-privileges to prevent privilege escalation.
This step alone eliminated many common container escape vectors. An attacker who compromises the web server can't install packages, modify system binaries, or access the host's Docker socket because the process lacks the CAP_SYS_ADMIN or CAP_DAC_OVERRIDE capabilities.
Step 3: Lock Down the Filesystem
Writeable filesystems are a common attack surface. We made the root filesystem read-only (--read-only) for all containers, and then mounted temporary filesystems (tmpfs) for directories that need write access, like /tmp and the application's cache directory. This prevents an attacker from modifying application code or persisting malicious binaries.
Additionally, we used Docker's --mount option to bind-mount sensitive directories like the Docker socket only when absolutely necessary—and never on production containers. The principle: if the container doesn't need to write to a path, make it read-only.
Step 4: Apply Seccomp and AppArmor Profiles
Default seccomp profiles already block many dangerous syscalls, but we customized them further to whitelist only the syscalls our application actually needs. This is a trade-off because it requires profiling the application. A simpler approach is to use Docker's default seccomp profile and then add --security-opt seccomp=path/to/profile.json if you need stricter rules. Similarly, AppArmor profiles can confine container processes to specific file paths and capabilities. We enabled AppArmor and used a custom profile that restricted access to only the application's data directories.
For a comprehensive guide on these hardening steps, refer to Hardening Docker Containers for Multi-Tenant Hosting: A Step-by-Step Isolation Guide.
The Contrarian View: Sometimes You Need VMs
No matter how hardened, containers share the host's kernel. A kernel vulnerability can break all isolation at once. That's why many security-conscious platforms run containers inside lightweight VMs—each tenant gets their own kernel. This adds overhead but provides a hardware-level boundary that containers alone cannot. If your tenants handle credit card data or health records, a hybrid approach (containers inside VMs) may be the right call. Don't assume container isolation is sufficient for your threat model; evaluate the sensitivity of the data and the regulatory requirements.
For a deeper comparison of isolation levels, read Designing a Multi-Tenant Docker Architecture: Choosing the Right Isolation Level.
Conclusion: Isolation Is a Stack, Not a Switch
The fix wasn't a single change—it was layering: network isolation, limited privileges, read-only filesystems, and syscall filtering. Even then, we accepted that perfect isolation is impossible with shared-kernel containers. For our highest-security tenants, we moved them to dedicated hosts. The lesson: trust no default. Audit your Docker setup as if a breach has already happened. The time to lock down is before the leak, not after.


