Run Docker containers on a non-rooted Android device — no Termux, no root, one APK.
Download APK (GitHub Releases)
After downloading, enable "Install from unknown sources" in Android Settings to sideload the APK.
The app embeds QEMU running Alpine Linux. Docker runs inside the VM. A FastAPI server inside the VM exposes a REST API over localhost, which the Flutter UI calls to manage containers.
Android App (Flutter + Kotlin)
└── VmManager ──launches──▶ QEMU (libqemu.so from nativeLibraryDir)
└── Alpine Linux VM
└── Docker daemon
└── API server (FastAPI :7080)
└── VmApiClient ──HTTP──▶ http://127.0.0.1:7080 (QEMU hostfwd)
- No root required — QEMU user-mode networking (SLIRP) works as a regular app
- No Termux — QEMU binaries ship as
jniLibsinside the APK; Alpine image is a bundled asset - Token auth — UUID token injected into the VM via QEMU kernel cmdline
api_token=<UUID>; guest reads it from/proc/cmdline
- Start / stop the embedded Linux VM
- Pull Docker images and run containers (image is cached across VM restarts)
- View real-time container logs
- Start / stop containers from the UI
- Terminal — shell access directly into the Alpine VM host
- Configurable vCPU count and RAM (1–4 cores, 512 MB–4 GB)
- Persistent notification while VM is running (ForegroundService)
- About screen — company info, project URL, open source licenses, APK download link
- Docker Desktop — the only requirement; everything else runs inside Docker
- Android 8.0+ (API 26+)
- ARM64 (aarch64) — only arm64-v8a QEMU binaries are included
- ~250 MB free storage for the APK
- ~2–3 GB free RAM while VM is running
docker-app/
├── lib/ Flutter UI (Dart)
│ ├── main.dart 4 tabs: Dashboard, Containers, Terminal, Settings
│ ├── screens/
│ │ ├── dashboard.dart VM status, start/stop, Pockr branding
│ │ ├── containers.dart Container list, logs
│ │ ├── terminal.dart VM shell terminal
│ │ ├── settings.dart vCPU / RAM sliders, About navigation
│ │ └── about.dart About screen, licenses, download link
│ └── services/
│ └── vm_platform.dart MethodChannel + VmState (health polling)
│
├── android/ Android native (Kotlin)
│ └── app/src/main/
│ ├── kotlin/com/example/dockerapp/
│ │ ├── DockerApp.kt Application singleton (holds VmManager)
│ │ ├── MainActivity.kt MethodChannel handler
│ │ ├── VmManager.kt Asset extraction + QEMU launch
│ │ ├── VmApiClient.kt HTTP client (auth token)
│ │ └── VmService.kt ForegroundService
│ ├── jniLibs/arm64-v8a/ QEMU + ~50 shared libs (committed to git)
│ │ ├── libqemu.so qemu-system-aarch64
│ │ ├── libqemu_img.so qemu-img
│ │ └── lib*.so (×48) shared dependencies (glib, zlib, etc.)
│ └── assets/
│ ├── vm/ base.qcow2.gz, vmlinuz-virt, initramfs-virt
│ └── bootstrap/ api_server.py, init_bootstrap.sh, requirements.txt
│
├── guest/ Source files baked into the Alpine base image
│ ├── api_server.py FastAPI server (Docker + VM shell management)
│ ├── init_bootstrap.sh First-boot setup script
│ └── requirements.txt
│
├── docker/
│ └── Dockerfile.build Ubuntu → JDK 17 → Android SDK → Flutter 3.22.2
│
└── scripts/
├── build_apk.sh Build APK inside Docker ← primary build script
├── build_alpine_base.sh Build Alpine base image with Docker + Python baked in
├── alpine_build_inner.sh Inner image build logic (runs inside Alpine container)
└── firebase_test.sh Run Robo test on Firebase Test Lab
git clone <repo-url>
cd docker-appThe base image bundles Alpine Linux with Docker, Python, and the API server pre-installed. This step takes ~10–15 minutes and only needs to be rerun when guest/ files change.
./scripts/build_alpine_base.sh
# Output: android/app/src/main/assets/vm/base.qcow2.gz (~102 MB)The QEMU binaries and kernel/initrd are already committed in jniLibs/ and assets/vm/ — no separate acquisition step is needed.
./scripts/build_apk.sh release
# Output: build/pockr-release.apk (~164 MB)For a debug build: ./scripts/build_apk.sh → build/pockr-debug.apk (~220 MB)
First build takes ~10 minutes (downloads JDK + Android SDK + Flutter inside Ubuntu Docker image). Subsequent builds reuse the cached builder image.
adb install -r build/pockr-release.apk- Open the app → tap Start VM
- Assets extract to app-private storage on first launch (~10–30 seconds)
- QEMU boots Alpine Linux (~30–60 seconds)
init_bootstrap.shruns on the very first boot — installs Docker and starts the API server (~5–10 minutes)- Once the
/healthcheck passes, the dashboard shows RUNNING and containers can be managed
First boot only is slow because Docker is installed from Alpine packages inside the VM. Subsequent boots take 30–60 seconds, and previously pulled Docker images are available instantly (persistent
user.qcow2overlay).
All endpoints except /health require Authorization: Bearer <token>.
| Method | Path | Body / Query | Description |
|---|---|---|---|
| GET | /health |
— | {"status","runtime","version"} |
| GET | /containers |
— | List all containers |
| POST | /containers/start |
{"image","name","cmd","env","ports","network"} |
Pull image then run container |
| POST | /containers/stop |
{"name"} |
Stop a container |
| GET | /logs |
?name=&tail= |
Container logs |
| POST | /images/pull |
{"image"} |
Pull an image (300s timeout) |
| POST | /exec |
{"name","cmd"} |
Exec command in a container |
| POST | /vm/exec |
{"cmd"} |
Run shell command on VM host |
/containers/start runs docker pull (up to 300 s) then docker run (up to 30 s). The Android VmApiClient uses a 360 s read timeout to cover both steps.
Stock Android kernels disable CONFIG_USER_NS (required for Docker rootless mode) and restrict cgroup access for regular apps. A QEMU VM provides a complete Linux environment with its own kernel — Docker runs normally inside it.
QEMU uses SLIRP user-mode networking (no root required):
hostfwd=tcp::7080-:7080— Android port 7080 → guest10.0.2.15:7080- Guest API server must listen on
0.0.0.0:7080(not127.0.0.1) so SLIRP-forwarded connections reach it - Android cleartext HTTP allowed for
127.0.0.1viares/xml/network_security_config.xml - ICMP/ping does not work inside the guest (SLIRP limitation)
A UUID token is generated on first app launch (vm_app_prefs) and injected into every QEMU boot via:
-append "... api_token=<TOKEN> ..."
The guest reads it from /proc/cmdline. Every API request must include Authorization: Bearer <token>.
Alpine's kernel (linux-virt) ships without nf_tables, bridge, or overlay modules. The Docker daemon is configured accordingly:
{
"iptables": false,
"bridge": "none",
"dns": ["8.8.8.8", "8.8.4.4"]
}Containers use --network host to share the VM's SLIRP network interface.
/etc/resolv.conf in the VM lists three nameservers with options use-vc (force TCP) to work around SLIRP UDP unreliability:
nameserver 10.0.2.3
nameserver 8.8.8.8
nameserver 8.8.4.4
options timeout:2 attempts:2 use-vc
user.qcow2 is a QCOW2 overlay backed by base.qcow2. It persists across VM restarts so pulled Docker images are available on the next boot without re-downloading. The overlay is only recreated when base.qcow2 is freshly extracted (i.e., on first install or app update).
Automated Robo tests run on Pixel2.arm (ARM64), Android 11 (API 30):
./scripts/firebase_test.sh <your-gcp-project> Pixel2.arm 30Requires service-account-key.json in the project root (gitignored).
| Component | License |
|---|---|
| QEMU | GPLv2 (TCG: BSD/Expat) |
| Alpine Linux | MIT / various |
| Docker | Apache 2.0 |
| Flutter | BSD 3-Clause |
All four components can be used in commercial and proprietary projects. QEMU runs as a separate process (not linked), so GPLv2 copyleft does not apply to your own app code — you only need to include the license text and make the QEMU source available.
See LICENSES.md for the full breakdown and compliance checklist.
- termux-docker-no-root — community validation of the VM-based approach this project is based on