Rootless container filesystems for Termux and Linux.
rootd-fs pulls OCI/Docker images and turns them into container
filesystems you can enter as a shell — no root, no daemon, no
namespaces required. It speaks the registry protocol directly and hands
execution to either PRoot or udocker.
rootd install ubuntu --autostartThat pulls Ubuntu 24.04, prepares it, and makes every new terminal open inside it.
git clone https://github.com/ivansslo/rootd-fs
cd rootd-fs
./install.shWith the udocker back-end as well:
ROOTD_WITH_UDOCKER=1 ./install.shRequirements: Python 3.9+ and proot. The installer handles both on
Termux and Debian-family systems. There are no Python dependencies.
rootd install <preset|image> pull an image and create a box
rootd enter [box] open a shell inside it
rootd sh [box] -- <cmd> run one command
rootd svc [box] run a preset's service in the foreground
rootd ls list boxes
rootd info [box] show details
rootd rm <box> delete a box
rootd rename <old> <new> rename a box
rootd default [box] show or set the default box
rootd autostart on|off|status manage the login-shell hook
rootd backup [box] write a box to an archive
rootd restore <archive> recreate a box from an archive
rootd completion <shell> print or install tab completion
rootd docker [box] --host point a Docker client at a remote daemon
rootd tailscale [box] <action> manage Tailscale inside a box
rootd ssh [box] -- user@host SSH out from inside a box
rootd caps show kernel capabilities and limits
rootd purge remove every box, cache and shell hook
rootd login <registry> store registry credentials
rootd logout <registry> remove them
rootd logins list stored logins (never secrets)
rootd presets list curated images
rootd doctor check the host environment
rootd prune clear the layer cache
A box is one container filesystem plus a small JSON record. Boxes live
under $PREFIX/var/lib/rootd-fs on Termux and $XDG_DATA_HOME/rootd-fs
elsewhere.
rootd install ubuntu # preset
rootd install nextcloud # preset with a service
rootd install termux-docker # Termux userland image
rootd install ghcr.io/owner/img:tag # any registry
rootd install alpine --name build --arch arm64
rootd enter ubuntu
rootd sh ubuntu -- apt update
rootd svc nextcloud # then open http://localhost:8080
rootd enter ubuntu --dry-run # print the command, run nothing| Preset | Image | Notes |
|---|---|---|
ubuntu |
ubuntu:24.04 |
general purpose |
debian |
debian:bookworm |
small and stable |
alpine |
alpine:latest |
~8 MB, musl |
arch |
archlinux:latest |
amd64 only upstream |
fedora |
fedora:latest |
recent toolchains |
void |
void-glibc:latest |
from ghcr.io |
termux-docker |
termux/termux-docker |
Termux prefix layout |
docker |
docker:cli |
client for a remote daemon |
tailscale |
tailscale/tailscale |
mesh VPN, userspace mode |
netshell |
alpine |
ssh, mosh, rsync, curl toolbox |
nextcloud |
nextcloud:stable-apache |
service on :80 |
postgres |
postgres:16-alpine |
service on :5432 |
nginx |
nginx:alpine |
service on :80 |
python |
python:3.12-slim |
dev image |
node |
node:22-slim |
dev image |
Any name that is not a preset is treated as a plain image reference, so the whole of Docker Hub, GHCR, Quay and private registries are reachable.
Installing a box does not change your shell. After
rootd install ubuntu you still get your normal Termux prompt. Auto-entry
is opt-in — silently hijacking your shell would be a good way to lock you
out of your own phone.
To opt in, either install with the flag or enable it afterwards:
rootd install ubuntu --autostart # at install time
rootd autostart on ubuntu # or later, for an existing box
rootd autostart status
rootd autostart offOnce enabled, every new Termux session and tab opens inside the box —
Termux's default bash sources ~/.bashrc for interactive shells, so no
extra configuration is needed.
It appends a marked block to .bashrc, .zshrc, or config.fish and
saves a .rootd-bak copy first. The block is written to fail safe:
- it only runs in interactive shells, so scripts,
scpandrsyncare unaffected; - it refuses to run when
ROOTD_BOXis already set, so it cannot recurse; ROOTD_NO_AUTO=1skips it without editing any file;- if the box is missing or its engine is gone, it prints a note and leaves you on the host shell rather than a dead terminal.
If you ever need to recover:
ROOTD_NO_AUTO=1 bash
rootd autostart offecho "$GITHUB_PAT" | rootd login ghcr.io -u ivansslo --password-stdin
rootd install ghcr.io/ivansslo/private-app:latestCredentials are checked against the registry before being saved, so a
bad token fails at login rather than at your next pull. They are stored
in auth.json at mode 0600 using Docker's format, and an existing
~/.docker/config.json is read automatically — if you have already run
docker login, pulls may just work.
rootd logins # registries and usernames, never secrets
rootd logout ghcr.io
rootd install --anonymous … # ignore stored credentials for one pullUse --password-stdin. A secret passed as --password lands in your
shell history and in the process list, and rootd-fs will warn you about
it. Note also that Docker's storage format is base64 — that is
encoding, not encryption; the file mode is the real protection.
Do you need to configure GitHub secrets for this repository?
No. Tests, builds, releases and GHCR pushes all use the automatic
GITHUB_TOKEN. PyPI publishing uses Trusted Publishing (OIDC) with no
stored token. See docs/PUBLISHING.md.
A Docker daemon cannot run on a stock Android kernel. This is not a
rootd-fs limitation and no configuration works around it. dockerd
needs real uid 0, a writable cgroup hierarchy, overlayfs, and veth
pairs. PRoot only fakes uid 0 — it rewrites syscall arguments, it does
not grant kernel privilege. Run rootd caps to see exactly which
features your kernel is missing.
What works, and works well, is the Docker client pointed at a machine that can run containers:
rootd install docker
rootd docker docker --host ssh://opc@your-oci-server
rootd sh docker -- docker ps
rootd sh docker -- docker compose up -dThis is not a compromise — the Docker CLI has always been a REST client
over a socket, and DOCKER_HOST is the supported way to aim it
somewhere else. You get the full command set against your server's real
daemon, with its real cgroups and real overlayfs.
Combined with Tailscale, your server needs no public port at all:
rootd tailscale tailscale daemon # terminal 1
rootd tailscale tailscale up --ssh # terminal 2
rootd docker docker --host ssh://opc@100.x.y.z # tailnet addressIf you genuinely need a local daemon on the phone, the only real option is a full VM with its own kernel (QEMU), which is out of scope here.
rootd install tailscale
rootd tailscale tailscale daemon # foreground daemon
rootd tailscale tailscale up --ssh # authenticate
rootd tailscale tailscale ip # show tailnet address
rootd tailscale tailscale statusOn Android /dev/net/tun is not usable from an unprivileged process, so
the daemon runs in userspace networking mode. That means:
- the node is reachable from your tailnet (so your OCI server can SSH into the phone);
- traffic originating in the box can egress through the SOCKS5 proxy on
localhost:1055; - it does not create a phone-wide VPN — other Android apps keep using the normal network.
--ssh enables Tailscale SSH, which is the simplest way to reach the
phone from your server without managing keys.
rootd install netshell
rootd ssh netshell --keygen # generate a key in the box
rootd ssh netshell --save opc@your-oci-server # remember a default
rootd ssh netshell # connect
rootd ssh netshell -L 8080:localhost:80 -- opc@serverKeeping the client inside a box means keys, known_hosts and config
live in one place that rootd backup captures.
proot (default) |
udocker |
|
|---|---|---|
| Mechanism | ptrace syscall rewriting | PRoot / Fakechroot / rootless runc |
| Works on stock Android | yes | mostly (P-modes) |
| Speed | moderate | F-modes are much faster |
| Root emulation | yes | P and R modes only |
rootd install alpine --engine udocker --mode F1udocker modes: P1 P2 (PRoot), F1–F4 (Fakechroot), R1–R3
(rootless runc/crun), S1 (Singularity). On Android, unprivileged user
namespaces are usually disabled and seccomp filtering is often broken,
so P2 is the default there; P1 is the default on Linux.
udocker's own storage is redirected under the rootd-fs state directory, so uninstalling removes everything.
rootd backup ubuntu # -> ubuntu-20260727-1430.tar.xz
rootd backup ubuntu -o /sdcard/ub.tar.xz --compress gz
rootd restore ub.tar.xz # original name from the manifest
rootd restore ub.tar.xz --name staging # or a new oneArchives are plain tars, so tar tf works on them. Each carries a
rootd-backup.json manifest recording the image, architecture, engine,
preset, mounts, and environment, so a restored box comes back configured
rather than bare.
Restore runs through the same traversal-hardened extractor as image layers: a crafted archive cannot write outside the box directory. A restore that fails part way removes the partial box rather than leaving a broken one.
rootd completion bash --install
rootd completion zsh --install
rootd completion fish --installWithout --install the script is printed to stdout. Completion covers
subcommands, flags, engine and mode values, and completes box names and
preset names dynamically from your actual install.
| Variable | Effect |
|---|---|
ROOTD_NO_AUTO=1 |
skip the login-shell hook |
ROOTD_STATE_DIR |
where boxes are stored |
ROOTD_CACHE_DIR |
where layers are cached |
ROOTD_NO_SECCOMP=1 |
disable PRoot seccomp (slower, fixes some kernels) |
ROOTD_UDOCKER_MODE |
default udocker mode |
ROOTD_PROOT_BIN |
use a specific proot binary |
NO_COLOR |
disable colour |
rootd-fs keeps everything under one tree, so removal is complete and
predictable. On Termux that tree is
$PREFIX/var/lib/rootd-fs; elsewhere it follows XDG.
rootd purge # inventory, confirm, then deleteIt prints exactly what will go, with sizes, and requires you to type
purge to proceed. Then remove the program itself:
pip uninstall rootd-fsOptions:
rootd purge --keep-cache # keep downloaded layers for a reinstall
rootd purge -y # no prompt (scripts)
rootd prune # only the layer cache, keep the boxes
rootd rm <box> # only one boxBack up first if you care about anything inside a box —
rootd backup <box> writes an archive that survives the purge, as long
as you write it somewhere outside the state tree (your home directory or
/sdcard).
If rootd itself is broken and you just want the disk space back:
# 1. the data (this is the big one)
rm -rf $PREFIX/var/lib/rootd-fs
# 2. the launcher and the package
rm -f $PREFIX/bin/rootd $PREFIX/bin/rootd-fs
pip uninstall rootd-fs
# 3. the completion files
rm -f $PREFIX/share/bash-completion/completions/rootd
rm -f $PREFIX/share/zsh/site-functions/_rootd
rm -f $PREFIX/share/fish/vendor_completions.d/rootd.fish
# 4. the autostart block, if you enabled it
# (delete the lines between the two rootd-fs markers)
nano ~/.bashrcIf an autostart hook is leaving you stuck in a container shell, start a session that skips it first:
ROOTD_NO_AUTO=1 bash
rootd autostart offThat works even when the box itself is broken, because the hook checks the box before entering and falls through to the host shell if it fails.
Rootless containers are a convenience boundary, not a security
boundary. PRoot and fakechroot emulate privilege; they do not confine
a hostile process. Do not use rootd-fs to run code you do not trust.
Port mappings shown by rootd svc are advisory: rootless containers
share the host network namespace, so a service binding :8080 is simply
reachable at localhost:8080.
python3 -m pytest # 170 tests, no network needed
python3 -m rootdfs.cli --helpLayer extraction is covered by dedicated traversal and whiteout tests; the shell hook is covered by tests asserting it cannot trap the user.
MIT — see LICENSE.
This is original work. It implements published, open specifications
(the OCI image spec and the Docker Registry HTTP API v2) and invokes
proot and udocker as external programs; it contains no code from
those projects.