Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
doos
Docs: README · SPEC · ARCHITECTURE · RESEARCH · COMPARISON · AGENTS · CHANGELOG · CI
User-level containers in C++20: overlayfs, user/mount/pid namespaces, slirp4netns or pasta networking, optional images (Alpine, Ubuntu, Debian, or your own rootfs), memory/cpu/pid/size limits, default hardening (capabilities, seccomp). No root at runtime.
doos list
doos get_or_create [OPTIONS] NAME
doos attach [OPTIONS] NAME [-- CMD...]
doos delete [--yes] NAME
doos image list | add [--sha256 HEX] SOURCE [NAME] | delete [--yes] NAME
doos apparmor # profile text for this binary (Ubuntu 24.04+)
Version: see VERSION. Release history: CHANGELOG.md. License: MIT.
Build: cmake -S . -B build && cmake --build build. Test: tests/run.sh.
Requirements: Linux >= 5.11, slirp4netns (default network mode), pasta (only for --net pasta), gnupg (signature checks; optional), uidmap (newuidmap/newgidmap, for more than one id inside; falls back to one id), curl, tar and sha256sum (images). On Ubuntu 24.04+ (kernel.apparmor_restrict_unprivileged_userns=1) AppArmor must allow user namespaces for the
binary. Generate a profile for the exact path doos is installed at, once:
doos apparmor | sudo tee /etc/apparmor.d/doos-local >/dev/null && sudo apparmor_parser -r /etc/apparmor.d/doos-local
If you skip this, doos tells you the exact command (with a file name unique to the binary's path, so installing doos
in a second place does not replace the first profile). apparmor/doos is a static profile for packaging that covers
/usr/local/bin, /usr/bin, /opt/doos/bin, ~/.local/bin, ~/bin and ~/projects/doos/build.
See SPEC.md (behavior, limits) and docs/RESEARCH.md (what works, measurements, known gaps).
Usage examples
doos attach dev # host mode: your system's /usr as base, shell as root inside
doos attach --image alpine box # first use downloads and verifies Alpine, then opens a shell
doos attach --image ubuntu web -- apt-get update
doos attach --image ubuntu-24.04 old # a specific Ubuntu release
doos attach --size 8g big # raise the writable-layer cap (default 4g)
doos attach --net none sandbox -- sh # no network at all
doos list
doos image list # what is installed, what the catalog offers
doos delete dev # dry run: shows what would go
doos delete --yes dev # really deletes
doos image delete --yes alpine # refused while a container uses it
Your own image (a root filesystem tarball):
doos image add https://example.org/debian-rootfs.tar.xz --sha256 <hex> debian # URL needs the hash
doos image add ./rootfs.tar.gz mybase # local file
doos attach --image mybase c1
Host mode or image mode
| Host mode (default) | Image mode (--image) |
|
|---|---|---|
| Base system | your /usr, /opt, copy of /etc |
the image, extracted once and shared |
| Needs a download | no | first use only |
apt-get install |
works when the host is Debian/Ubuntu based | works for the image's own package manager (apt, apk) |
| Sees host files | /usr, /opt, readable /etc at creation |
nothing from the host |
| Best for | quick throwaway shells with your tools | reproducible, separate systems |
Writes always stay in the container (copy-on-write); /root and /var persist, /tmp is memory-backed and lost on exit. A container is running while its first attach command is alive; more attach calls join it. Data persists until delete --yes.
Networking
Every example below was run on the development machine. Pick the mode when you create the container; it cannot change later.
| Mode | Outbound | Host 127.0.0.1 from inside |
Reach a port served inside, from the host | Host abstract unix sockets |
|---|---|---|---|---|
slirp (default) |
yes | blocked | no | not shared |
pasta |
yes | reachable | yes (forwarded automatically) | not shared |
host |
yes | the same network | yes | shared |
none |
no | no | no | not shared |
# slirp (default): the internet works, the host's loopback does not
doos attach web -- curl -sS -o /dev/null -w "%{http_code}\n" https://example.com # 200
doos attach web -- curl -sS -m 3 http://127.0.0.1:8099 # fails: host loopback is blocked
doos attach web -- getent hosts example.com # DNS works (resolver 10.0.2.3)
# pasta: behaves like the host network, in a private namespace
doos attach --net pasta dev -- curl -sS http://127.0.0.1:8099 # reaches a service on the host's loopback
doos attach dev -- python3 -m http.server 8088 --bind 0.0.0.0 # then, on the host: curl http://127.0.0.1:8088/
doos attach --image alpine --net pasta al -- wget -qO- https://example.com # works with images too
# none: no network at all, only loopback
doos attach --net none iso -- curl -m 3 https://example.com # curl exits 6, /proc/net/dev lists only lo
# host: the host's own network namespace, explicit opt-in (prints a note recommending pasta)
doos attach --net host h -- curl -sS -o /dev/null -w "%{http_code}\n" https://example.com # 200
Notes, all measured:
slirphas no inbound path: a server started in aslirpcontainer is not reachable from the host (connection fails). Usepastafor that.pastanotices a newly bound port after about 2 seconds, so start the server and give it a moment before connecting from the host.- Two
pastacontainers can talk to each other through the host's loopback: start a server in one (python3 -m http.server 8066 --bind 0.0.0.0), wait about 5 seconds, thendoos attach cli -- curl http://127.0.0.1:8066/from the other returns 200. The host's own public address is not forwarded; use127.0.0.1or the default gateway address. pastastarts in about 65 ms per attach,slirpabout 40 ms,noneabout 10 ms.
Options
| Option | Meaning | Default | Later |
|---|---|---|---|
--image NAME |
alpine, ubuntu, ubuntu-24.04, debian, debian-bookworm, or your own | host system | fixed |
--net M |
slirp (outbound only, host loopback blocked), pasta (host-like, private sockets), host, none |
slirp | fixed |
--ids X |
single or subuid |
subuid if possible | fixed |
--hardening H |
default or relaxed |
default | fixed |
--i-understand |
required on every call that sets or uses --hardening relaxed or --net host |
absent | fixed |
--size S |
writable data cap | 4g | changeable |
--memory S |
memory cap | 8g | changeable |
--pids N |
process/thread cap | 4096 | changeable |
--cpus N |
cpu cap, e.g. 0.5, 2 |
all cores | changeable |
Changeable options apply the next time the container starts. Examples: doos attach --memory 2g --cpus 1 build, doos attach --net pasta --image debian-bookworm web.
Limits, size cap and hardening
- Limits (
--memory,--pids,--cpus): cgroup v2, measured here: a 256m container touching 400 MB is killed whole and doos says so; 80 forks against--pids 32fail and are reported;--cpus 0.5held 4 busy loops to 2.3 CPU-seconds in 4.3 s of wall time. - Size cap:
--sizelimits what a container writes. Checked from inside the id map (so other users' files count), warns at 80%, freezes at 100% (nothing is deleted), thaws below 95%. A seconddoos attach NAME -- ...is never frozen, so you can clean up. No single file can exceed the cap. - Hardening (default): capabilities reduced to Docker's default set, a seccomp filter (no mounts, keyrings, bpf, perf, io_uring, nested user namespaces, kexec, module loading...), and a host
/etcallowlist instead of a full copy.--hardening relaxedswitches all three off for tools that need them (container runtimes, Chromium's sandbox). - All limits need cgroup v2 delegation (standard on systemd desktops). Without it doos prints that nothing is enforced.
Configuration
| Variable | Meaning |
|---|---|
DOOS_HOME |
State directory (default ~/.local/share/doos). No , or : allowed. |
XDG_DATA_HOME |
Used as $XDG_DATA_HOME/doos when DOOS_HOME is unset. |
Troubleshooting
| Message | Cause and fix |
|---|---|
write /proc/<pid>/uid_map ... blocked or mount ...: Permission denied |
AppArmor restricts user namespaces. Install apparmor/doos (command above). |
slirp4netns failed to start |
Install slirp4netns, or use --net none / --net host. |
subuid mapping unavailable |
Install uidmap and make sure /etc/subuid and /etc/subgid list your user, or use --ids single. |
sha256 mismatch |
The download is not what the publisher lists; nothing was installed. Retry or check the source. |
a URL image needs --sha256 |
Unverifiable input is rejected by design; pass the hash from the publisher. |
limits are not enforced and the size cap only warns |
No delegated cgroup v2 (no systemd user manager). Limits and cap are advisory. |
was killed by its memory limit |
Raise --memory. /tmp is memory-backed and counts. |
hit its process limit |
Raise --pids; threads count as processes. |
AppArmor restricts user namespaces for this binary |
Run the command doos prints (doos apparmor | sudo tee ... && sudo apparmor_parser -r ...), once per install path. |
Operation not permitted from mount, unshare, keyctl, io_uring |
Hardening blocked it. Use --hardening relaxed on that container. |
signature NOT checked (gpg is not installed) |
Install gnupg for signature checks; the checksum still applies. |
doos: seeded ...: unreadable skipped: N |
Host entries the user cannot read were left out of the container. Informational. |
state path must not contain ',' or ':' |
Set DOOS_HOME to a plain path. |
container 'x' is running on delete |
Exit the attached command first. |
container limit reached (64) |
Delete one first. |
Safety summary
Host files are never written: system dirs are reached only through overlayfs. Host /home, /sys and host
loopback are not reachable in the default network mode. Images are verified before they are extracted: sha256
from the publisher, and for Alpine and Ubuntu a signature checked against a key pinned by fingerprint. Processes
run with Docker's default capability set and a seccomp filter. Memory, pids, cpus and written data are capped.
--net host removes network isolation and is opt-in (--net pasta is the safer host-like choice).
What is still not covered is listed at the end of docs/RESEARCH.md.
Is it a security-wise Docker replacement? Same class as rootless Podman by design (no root daemon, user namespaces, seccomp, dropped capabilities, cgroup limits), but unaudited, tested on one machine, with a seccomp denylist and no per-container AppArmor/SELinux profile. Fine for your own tools; for untrusted code use rootless Podman, gVisor or Kata. Details: COMPARISON section 6.1.
Development
make help lists the targets: make build, make test (end-to-end, needs network), make audit (named CVE regression checks, see audit/README.md), make valgrind (memcheck against a fixed set of invocations, see valgrind/README.md), make bench (re-measure startup, sizes and the cpu limit), make charts (rebuild the SVG charts and the generated numbers in the comparison), make docs, make release KIND=patch MSG="...". Build and test details are in AGENTS; the charts and how they are generated are in COMPARISON section 3 and docs/charts/. CI (.gitea/workflows/ci.yml) runs build, valgrind, test and audit on every push; see docs/CI.md for what each needs and the one-time runner setup.
Project docs
| File | Content |
|---|---|
SPEC.md |
Behavior contract and limits |
docs/ARCHITECTURE.md |
Code map and flows |
docs/RESEARCH.md |
What works unprivileged, measurements, remaining limits |
COMPARISON.md |
doos next to Docker, Podman, bubblewrap, nspawn, LXC/Incus, Apptainer, Distrobox, gVisor/Kata, with pros, cons, measurements and a conclusion |
AGENTS.md |
Rules for AI agents, release workflow |
CHANGELOG.md |
Release history |
audit/README.md |
Named CVE regression checks run against build/doos (make audit) |
valgrind/README.md |
Memcheck against a fixed set of invocations (make valgrind) |
docs/CI.md |
Gitea Actions: what runs where, runner setup |
Not sure whether doos is the right tool? Read the conclusion of COMPARISON first.