Run Real Docker on Android — No Root, No Tricks, Just QEMU
Phone battery tip: If your phone has a “Protect Battery” or “Maximum 85%” setting (Samsung does), turn it on. The phone will be plugged in 24/7, and capping the charge doubles the battery’s lifespan.
The architecture (mental model)
Here’s what we’re building:
┌────────────────────────────────────────────────────┐ │ Your Android phone (not rooted) │ │ │ │ ┌──────────────────────────────────────────────┐ │ │ │ Termux (regular Android app) │ │ │ │ - QEMU binary (qemu-system-aarch64) │ │ │ │ - The .qcow2 disk image │ │ │ │ - Boot scripts │ │ │ │ - sshd (port 8022) → your computer SSHes │ │ │ │ in here for management │ │ │ └─────────────────┬────────────────────────────┘ │ │ │ launches │ │ ┌─────────────────▼────────────────────────────┐ │ │ │ QEMU VM (looks like real ARM64 hardware) │ │ │ │ - Debian 12 (bookworm) │ │ │ │ - Real dockerd + Docker Compose v2 │ │ │ │ - sshd (port 22 → forwarded to 2222) │ │ │ │ - systemd works (unlike in proot) │ │ │ └──────────────────────────────────────────────┘ │ └────────────────────────────────────────────────────┘ ↑ │ SSH from your computer │ ssh phone-vm → port 2222 → Debian VM │ ssh phone-termux → port 8022 → Termux shell
Two SSH targets. phone-vm drops you into Debian where Docker lives. phone-termux drops you into Termux itself (the Android-side layer), useful for troubleshooting the VM or restarting QEMU.
Why QEMU and not just proot-distro? Three reasons:
- proot-distro has no systemd. No systemd means no
systemctl enable docker. You’d have to manually start the daemon on every login. - proot-distro has no cgroups. No cgroups means no container resource control. Docker will refuse to start.
- proot-distro has no real namespaces. No namespaces means no isolation. Containers would see each other.
QEMU gives us a real Linux kernel with all three. The cost is speed — every instruction is translated by QEMU’s TCG (Tiny Code Generator) software emulator. That’s why boot takes 20–30 minutes.
Step 1: Install Termux (the right way)
Do not install Termux from the Play Store. The Play Store version is frozen at v0.101 from 2020 and has a known security vulnerability. Install from F-Droid or the GitHub releases — but pick one and stick with it, because they’re signature-incompatible with each other.
The cleanest option is F-Droid. Go to f-droid.org/en/packages/com.termux on your phone and install the APK.
While you’re at it, also install Termux:Boot from F-Droid: f-droid.org/en/packages/com.termux.boot. This is what makes QEMU auto-start when the phone reboots. We’ll configure it in Step 7.
After installing Termux:Boot, open it once. Just tap the icon, then close. This registers it with Android’s BOOT_COMPLETED broadcast. If you skip this, the auto-start script in Step 7 won’t fire.
Now open Termux. You should see a $ prompt. Run:
pkg update -y && pkg upgrade -y
This updates the package lists and upgrades everything. First run takes 1–2 minutes.
If it hangs on a mirror, run termux-change-repo, pick “Main repository”, and select a mirror close to you. Indonesian users: linux.domainesia.com and mirror.nevacloud.com are in the Asia group.
Step 2: Set up SSH so you can work from your computer
Typing long commands on a phone keyboard is miserable. Let’s fix that by setting up SSH from your computer.
2.1 Grant storage access (one-time)
In Termux:
termux-setup-storage
A dialog pops up on your phone asking for storage permission. Tap “Allow”. This creates ~/storage/ symlinks to shared storage — not strictly needed for QEMU, but useful later if you want to back up your disk image.
2.2 Set a Termux password
passwd
Type a password twice. You’ll need this for SSH (though we’ll set up key-based auth in a moment).
2.3 Add your computer’s SSH public key
On your computer, get your public key:
# macOS / Linux / WSL cat ~/.ssh/id_ed25519.pub
If you don’t have one, generate it:
ssh-keygen -t ed25519 -C "your-email@example.com" # Press Enter through all the prompts
Copy the output (a string starting with ssh-ed25519 AAAA...).
Back in Termux on the phone:
mkdir -p ~/.ssh && chmod 700 ~/.ssh echo 'PASTE_YOUR_PUBLIC_KEY_HERE' >> ~/.ssh/authorized_keys chmod 600 ~/.ssh/authorized_keys
Replace PASTE_YOUR_PUBLIC_KEY_HERE with the key you copied. Keep the single quotes around it.
2.4 Start the SSH server
sshd
No output means it started. Termux’s sshd listens on port 8022 (not the usual 22 — Android reserves 22 for the system).
2.5 Find your phone’s IP
ip addr show wlan0 | grep inet
You’ll see something like inet 192.168.0.9/24. Note the IP (without the /24).
2.6 Set up SSH config on your computer
On your computer, edit ~/.ssh/config and add:
Host phone-termux HostName 192.168.0.9 Port 8022 User u0_a892 IdentityFile ~/.ssh/id_ed25519 ControlMaster auto ControlPath ~/.ssh/controlmasters/%r@%h:%p ControlPersist 10m
Replace 192.168.0.9 with your phone’s IP. The User looks weird (u0_a892) — that’s Termux’s Android UID, which is also its Linux username. The number after u0_a varies per install; to find yours, run whoami in Termux.
Create the controlmasters directory (needed for connection multiplexing, which makes repeated SSH commands nearly instant):
mkdir -p ~/.ssh/controlmasters
Now test:
ssh phone-termux whoami # → u0_a892
If that works, you’re set. From now on, you can run commands in Termux from the comfort of your computer’s keyboard.
Step 3: Download Debian and build the cloud-init seed
Back in Termux (either on the phone directly, or via ssh phone-termux from your computer):
3.1 Install QEMU and friends
pkg install -y qemu-system-aarch64 openssh curl wget genisoimage
This installs:
-
qemu-system-aarch64— the emulator itself (~400 MB, includes UEFI firmware) -
openssh— for the SSH server we already started -
curl/wget— for downloading the Debian image -
genisoimage— for building the cloud-init seed ISO
3.2 Acquire a wake lock (CRITICAL — do not skip)
termux-wake-lock
This tells Android “don’t kill this process when the screen is off.” Without it, Android’s Doze mode will murder QEMU 5–10 minutes after your screen goes dark, and your VM will die mid-boot. You only need to run this once per Termux session — but it’s easiest to put it in your boot script (Step 7) so it’s always active.
3.3 Download the Debian cloud image
mkdir -p ~/qemu-vm && cd ~/qemu-vm wget https://cloud.debian.org/images/cloud/bookworm/latest/debian-12-genericcloud-arm64.qcow2
This is a 500 MB qcow2 file — Debian’s official “generic cloud” image for ARM64. It’s built for exactly this use case (cloud VMs), so it has cloud-init pre-installed and no desktop environment.
3.4 Resize the disk
qemu-img resize debian-12-genericcloud-arm64.qcow2 16G
The downloaded image is ~2 GB (logical size). We resize to 16 GB so there’s room for Docker, containers, and pulled images. The qcow2 format only allocates disk space as it’s used, so this won’t actually consume 16 GB on your phone immediately.
Rename it for clarity:
mv debian-12-genericcloud-arm64.qcow2 debian-12-arm64.qcow2
3.5 Create the cloud-init seed
Cloud-init is how we configure the VM on first boot: set hostname, create users, add SSH keys. It reads from a “seed” — in our case, a small ISO file attached as a virtual CD-ROM.
Create user-data:
cat > user-data <<'EOF' #cloud-config hostname: docker-phone users: - name: sulthon sudo: ALL=(ALL) NOPASSWD:ALL shell: /bin/bash ssh_authorized_keys: - ssh-ed25519 AAAA...YOUR_KEY_HERE... your-email@example.com ssh_pwauth: false disable_root: false package_update: true packages: - qemu-guest-agent - ca-certificates - curl growpart: mode: auto devices: ['/'] ignore_growroot: false EOF
Edit the SSH key to match your actual ~/.ssh/id_ed25519.pub from Step 2.3. This is what lets you SSH into the Debian VM later. Change the username sulthon to whatever you like — just remember it for later steps.
Create meta-data:
cat > meta-data <<'EOF' instance-id: docker-phone-001 local-hostname: docker-phone EOF
Build the seed ISO:
genisoimage -output seed.iso -volid cidata -joliet -rock user-data meta-data
The -volid cidata is critical — cloud-init looks for a volume labeled exactly cidata (or CIDATA). If you get this wrong, cloud-init won’t run and you’ll have no users, no SSH keys, no nothing.
Step 4: The QEMU launcher script
This is the script that boots Debian. There’s a lot going on, so let’s break it down carefully.
Copy the UEFI firmware variables first (one-time):
cp $PREFIX/share/qemu/edk2-aarch64-vars.fd ~/qemu-vm/edk2-vars.fd truncate -s 64M ~/qemu-vm/edk2-vars.fd
This creates a writable copy of the UEFI NVRAM (the edk2-vars.fd file). The truncate grows it to 64 MB for safety margin. Don’t skip this — without the vars file, UEFI state won’t persist across VM reboots, and your VM won’t boot a second time.
Now create the launcher script. This is the heart of the whole setup:
cat > ~/boot-debian-mon.sh <<'EOF' #!/data/data/com.termux/files/usr/bin/bash set +e VM_DIR="/data/data/com.termux/files/home/qemu-vm" CODE="/data/data/com.termux/files/usr/share/qemu/edk2-aarch64-code.fd" VARS="$VM_DIR/edk2-vars.fd" IMG="$VM_DIR/debian-12-arm64.qcow2" SEED="$VM_DIR/seed.iso" MONSOCK="$VM_DIR/mon.sock" SERIALSOCK="$VM_DIR/serial.sock" LOG="$VM_DIR/debian-boot.log" pkill -9 -f qemu-system-aarch64 2>/dev/null sleep 2 rm -f $MONSOCK $SERIALSOCK setsid qemu-system-aarch64 \ -name docker-phone \ -machine virt,gic-version=3 \ -cpu max,aarch64=on,pmu=on \ -smp 6 \ -m 6144 \ -accel tcg,thread=multi,tb-size=512 \ -nodefaults \ -chardev socket,id=mon0,path=$MONSOCK,server=on,wait=off \ -mon chardev=mon0,mode=readline \ -chardev socket,id=ser0,path=$SERIALSOCK,server=on,wait=off,logfile=$LOG \ -serial chardev:ser0 \ -display none \ -drive if=pflash,format=raw,readonly=on,file=$CODE \ -drive if=pflash,format=raw,file=$VARS \ -drive file=$IMG,if=virtio,format=qcow2,cache=writeback \ -drive file=$SEED,if=virtio,format=raw,readonly=on \ -netdev user,id=net0,hostfwd=tcp::2222-:22,hostfwd=tcp::8080-:80,hostfwd=tcp::9000-:9000 \ -device virtio-net-pci,netdev=net0 \ -device virtio-rng-pci \ -rtc base=utc \ > $LOG 2>&1 & disown sleep 3 echo "QEMU PID: $(pgrep -f qemu-system-aarch64 | head -1)" echo "Monitor socket: $MONSOCK" echo "Serial socket: $SERIALSOCK" echo "Log: $LOG" EOF chmod +x ~/boot-debian-mon.sh
What each line does
The machine definition:
-
-machine virt,gic-version=3— QEMU’s “virt” machine, which is designed for VMs (not emulating any specific real phone). GIC version 3 is the modern ARM interrupt controller. -
-cpu max,aarch64=on,pmu=on— expose the maximum CPU feature set to the guest, including hardware crypto (ARMv8 crypto extensions). This makes TLS handshakes faster. -
-smp 6— give the VM 6 CPUs. Adjust based on your phone (8-core phones typically have a “big.LITTLE” layout; 6 leaves 2 for Android). -
-m 6144— give the VM 6 GB of RAM. Leave the rest for Android.
The emulator:
-
-accel tcg,thread=multi,tb-size=512— use TCG (software emulation) with multi-threading and a 512 MB translation block cache. This is the best TCG config for sustained throughput.
Storage:
- The first
-drive if=pflash,readonly=onis the UEFI firmware code (read-only). - The second
-drive if=pflashis the UEFI vars file (writable — this is why we made the copy earlier). - The
-drive file=$IMG,if=virtiois your Debian disk. - The
-drive file=$SEED,if=virtio,readonly=onis the cloud-init seed ISO.
Networking:
-
-netdev user,id=net0,hostfwd=tcp::2222-:22,...— user-mode networking with port forwarding. Anything connecting to port 2222 on the phone gets forwarded to port 22 inside the VM. We also forward 8080 and 9000 for web apps you might run later.
Process management (the tricky part):
-
setsid— detaches QEMU from Termux’s session leader. Without this, when SSH disconnects, Android kills the whole process tree. -
disown— removes QEMU from the shell’s job table. - Together, these mean QEMU survives SSH disconnects. Do not use plain
nohup— it’s not enough on Termux.
Step 5: First boot (patience required)
Launch the VM:
bash ~/boot-debian-mon.sh
You should see:
QEMU PID: 12345 Monitor socket: /data/data/com.termux/files/home/qemu-vm/mon.sock Serial socket: /data/data/com.termux/files/home/qemu-vm/serial.sock Log: /data/data/com.termux/files/home/qemu-vm/debian-boot.log
Now wait. 20–30 minutes. No, that’s not a typo. TCG software emulation is brutally slow.
You can watch the boot log from another SSH session:
# From your computer: ssh phone-termux tail -f ~/qemu-vm/debian-boot.log
When you see something like:
[ OK ] Started OpenSSH server
…and the log stops growing, the VM is ready. Verify from your computer:
ssh -p 2222 sulthon@192.168.0.9 hostname # → docker-phone
(Reminder: change sulthon to whatever username you put in user-data.)
If that works, add a second SSH config entry on your computer for the VM:
# append to ~/.ssh/config Host phone-vm HostName 192.168.0.9 Port 2222 User sulthon IdentityFile ~/.ssh/id_ed25519 ControlMaster auto ControlPath ~/.ssh/controlmasters/%r@%h:%p ControlPersist 10m
Now ssh phone-vm Just Works.
Step 6: Install Docker inside the VM
SSH into the VM and install everything:
ssh phone-vm
6.1 Install Docker
sudo apt-get update sudo apt-get install -y docker.io
The docker.io package is Debian’s official Docker package (version 20.10.24 as of this writing). It’s slightly older than Docker CE, but it’s in the main Debian repos and works perfectly. Expect this to take 25–35 minutes under TCG. Go get coffee.
6.2 Install Docker Compose v2
sudo mkdir -p /usr/libexec/docker/cli-plugins sudo curl -fSL -o /usr/libexec/docker/cli-plugins/docker-compose \ https://github.com/docker/compose/releases/download/v2.29.7/docker-compose-linux-aarch64 sudo chmod +x /usr/libexec/docker/cli-plugins/docker-compose
We download the binary directly because the docker-compose-v2 Debian package isn’t in bookworm. Placing it in cli-plugins/ means docker compose (with a space, not a hyphen) works.
6.3 Let your user run Docker without sudo
sudo usermod -aG docker $USER
You’ll need to log out and back in for this to take effect:
exit # back to your computer ssh phone-vm # back in
6.4 Configure Docker for the slow VM
This step is critical. Without it, docker pull will fail with TLS handshake timeout because TCG is too slow for Docker’s default timeouts.
sudo tee /etc/docker/daemon.json >/dev/null <<'EOF' { "max-concurrent-downloads": 1, "max-download-attempts": 5, "dns": ["8.8.8.8", "1.1.1.1"], "ip6tables": false, "ipv6": false } EOF sudo systemctl restart docker
What each setting does:
-
max-concurrent-downloads: 1— only download one layer at a time. Parallel downloads overwhelm TCG and time out. -
max-download-attempts: 5— retry failed layers. -
dns: [8.8.8.8, 1.1.1.1]— Docker’s embedded DNS sometimes can’t resolve registry hostnames under TCG; this forces public DNS. -
ip6tables: falseandipv6: false— disable IPv6. TCG’s IPv6 stack is even slower than IPv4, and many container images misbehave over IPv6 anyway.
6.5 Add ZRAM swap (recommended)
ZRAM is compressed swap in RAM. Under TCG, real disk I/O is brutal, so having compressed memory swap gives you a safety net for memory spikes.
sudo apt-get install -y zram-tools echo -e "ALGO=zstd\nPERCENT=75\nPRIORITY=100" | sudo tee /etc/default/zramswap sudo systemctl restart zramswap
This gives you ~4.3 GB of zstd-compressed swap (assuming 6 GB of VM RAM). zstd compresses roughly 3:1 on typical workload data, so it’s like having ~13 GB of effective memory.
6.6 Verify
sudo docker run --rm hello-world
Expect:
Unable to find image 'hello-world:latest' locally ... pulling layers ... Hello from Docker! This message shows that your installation appears to be working correctly.
The pull takes ~75 seconds (5 KB image, but TCG). The run takes ~15 seconds after that. If you see “Hello from Docker!”, you’re done with the hard part.
Step 7: Make it survive phone reboots
If the phone reboots (battery died, OS update, accidental power button press), you want QEMU to come back automatically. Termux:Boot handles this.
7.1 Create the Termux:Boot scripts
Back in Termux (via ssh phone-termux):
mkdir -p ~/.termux/boot
Create the first script — auto-start QEMU:
cat > ~/.termux/boot/01-start-vm.sh <<'EOF' #!/data/data/com.termux/files/usr/bin/bash sleep 15 termux-wake-lock 2>/dev/null pkill -9 -f qemu-system-aarch64 2>/dev/null sleep 2 bash ~/boot-debian-mon.sh EOF chmod +x ~/.termux/boot/01-start-vm.sh
The sleep 15 gives Android time to finish booting before we start hammering the CPU. The pkill is a safety net in case QEMU somehow came back half-alive.
Create the second script — auto-start Termux sshd:
cat > ~/.termux/boot/02-start-sshd.sh <<'EOF' #!/data/data/com.termux/files/usr/bin/bash sleep 5 sshd EOF chmod +x ~/.termux/boot/02-start-sshd.sh
7.2 Verify the scripts are registered
If you’ve opened Termux:Boot once (Step 1), Termux automatically picks up scripts in ~/.termux/boot/ and runs them at boot. To confirm:
ls -la ~/.termux/boot/ # Should show: # -rwx------ 1 u0_a892 u0_a892 ... 01-start-vm.sh # -rwx------ 1 u0_a892 u0_a892 ... 02-start-sshd.sh
7.3 Test it (optional but recommended)
Reboot your phone the normal Android way. After it restarts:
- Wait ~5 minutes for Android to fully boot.
- Wait another ~20 minutes for QEMU to cold-boot Debian.
- From your computer:
ssh phone-vm hostnameshould returndocker-phone.
If that works, your phone is a self-healing Docker host. You can unplug it, plug it back in, reboot it, whatever — it’ll come back.
Step 8: Use it from your computer like it’s local
Typing ssh phone-vm docker run ... for everything gets old. Let’s make the phone feel like a local Docker host.
8.1 Create a Docker context
On your computer:
docker context create phone --docker "host=ssh://sulthon@192.168.0.9:2222"
(Change sulthon to your VM username.)
Docker’s SSH integration bypasses your ~/.ssh/config aliases, so you need to add the phone to ~/.ssh/known_hosts explicitly:
ssh-keyscan -p 2222 -t ed25519 192.168.0.9 >> ~/.ssh/known_hosts
Verify:
docker --context phone ps docker --context phone images docker --context phone run --rm hello-world
8.2 Use Docker Compose against the phone
Anywhere you have a docker-compose.yml:
docker --context phone compose up -d docker --context phone compose logs -f docker --context phone compose down
This is the magic. The phone is now indistinguishable from a remote Docker host.
8.3 (Optional) Make it the default
docker context use phone
Now docker ps (without --context) hits the phone. Be careful with this — it’s surprising when you forget and accidentally push an image to the phone over Wi-Fi instead of your local Docker. I prefer to leave it as --context phone explicitly.
Troubleshooting: the 5 things most likely to break
1. “QEMU died after my SSH disconnected”
You forgot setsid and disown in the launcher script, or you launched it manually with nohup (which is not enough). Use the script from Step 4 as-is. Never launch QEMU directly with qemu-system-aarch64 ... — always go through the script.
2. “Docker pull fails with TLS handshake timeout”
You skipped the /etc/docker/daemon.json config in Step 6.4, or the config is malformed. Verify:
ssh phone-vm cat /etc/docker/daemon.json ssh phone-vm sudo systemctl restart docker
Then retry. If it still fails, check that max-concurrent-downloads is set to 1 and not to a higher number.
3. “Termux sshd isn’t running after reboot”
Either you forgot to open Termux:Boot once (Step 1), or the ~/.termux/boot/02-start-sshd.sh script isn’t executable. Fix:
ssh phone-termux chmod +x ~/.termux/boot/02-start-sshd.sh
And open the Termux:Boot app icon on your phone, just to be safe.
4. “The VM boots to a UEFI shell instead of Debian”
This is a known issue with Debian cloud images on some QEMU versions. The fix is to write a startup.nsh script to the EFI System Partition. This is fiddly — you need to boot Alpine from ISO with your qcow2 as a data disk, mount the ESP, and write the file. I’ll cover this in detail in a follow-up post. For now, if this happens, leave a comment on this post and I’ll walk you through it.
5. “Everything worked yesterday, but today it’s broken”
You hit Android’s phantom process killer. Modern Android (12+) kills background processes that use too much CPU. The fixes:
- Keep the phone plugged in. Battery-saver mode is brutal.
- Keep
termux-wake-lockactive. Runssh phone-termux termux-wake-lockto verify. - Disable Samsung Game Tuning / GOS if you’re on a Samsung device. It throttles sustained CPU workloads. Settings → Gaming Services → Game Booster → Maximum Performance (or similar).
- Check Settings → Battery → Termux → Unrestricted. This should be automatic via Termux:Boot, but if it’s not, set it manually.
What to do next
You now have a working Docker host that costs $0/month and fits in your pocket. Some ideas:
- Run a personal Traefik + whoami demo to verify compose works end-to-end. Port 8080 is already forwarded.
- Self-host Postgres for local dev. Use a volume so data survives container restarts.
- Run Watchtower to keep your containers auto-updated.
- Add Tailscale to the VM so you can reach your Docker host from anywhere — not just your Wi-Fi.
- Build images on the phone.
docker buildworks fine (slowly) under TCG. Useful for iterating without hitting your laptop’s battery.
The phone-as-server dream is real. It just takes QEMU to get there. Enjoy your free Docker host.
If you find bugs in this tutorial, please leave a comment — I’ll keep it updated. Happy hacking.
Fuente: Artículo original