Run Real Docker on Android — No Root, No Tricks, Just QEMU
You have an old Android phone in a drawer. It has 8 cores, 8–12 GB of RAM, and a fast SSD. It's collecting dust because the screen is cracked or the battery is tired. Meanwhile, you're paying $5/month for a 1 GB VPS to run the same Docker containers you could run on that phone for free.
The problem: Android doesn't run Docker. Docker needs the Linux kernel's cgroups, namespaces, and overlay filesystem. Android's kernel has those features compiled out for unprivileged apps, and there's no way to add them back without rooting the device.
The workaround everyone recommends is udocker or proot-distro. Those work for some things — pulling an image and chrooting into it — but they don't give you a real Docker daemon. You can't docker build. You can't run docker compose with networking. You can't use any tool that expects the Docker API to actually behave like Docker.
This tutorial shows the only path I've found that gives you a real Docker daemon running real containers on a non-rooted Android phone: run Debian in a QEMU virtual machine inside Termux. It's slow (10–25× overhead from software emulation), but it works — and it survives phone reboots.
By the end, you'll have:
- ✅ A Debian 12 VM running inside your phone
- ✅ Real
dockerd+ Docker Compose v2 - ✅ SSH access from your computer to the VM
- ✅ Auto-start on phone reboot (no manual intervention)
- ✅ A
docker contextsodocker --context phone compose upworks like it's local
Tested end-to-end on a Samsung Galaxy Note 10+ (SM-N975F, Exynos 9825, 12 GB RAM, Android 12, kernel 4.14.113) — not rooted.
📦 One-command setup: All scripts, configs, and the 1500-line playbook are at github.com/sulthonzh/android-docker-qemu. Run on both your phone (inside Termux) and your computer:
curl -fsSL https://raw.githubusercontent.com/sulthonzh/android-docker-qemu/main/install.sh | bashThe installer auto-detects which device it's on, downloads the repo over HTTPS, and runs the right setup. No sudo, no system-file changes, no telemetry — audit it first if you prefer. Star it if this article saves you a weekend.
What you'll need
| Requirement | Why | Cost |
|---|---|---|
| An Android phone (ARM64, 6+ GB RAM) | The host. Must be ARM64 — x86 phones don't exist, but just in case. | Free (you have one) |
| A computer (macOS, Linux, or Windows with WSL) | To SSH in from. The phone is the server, your computer is the client. | Free |
| Both devices on the same Wi-Fi | For SSH. (You can do this remotely later with Tailscale, but that's out of scope here.) | Free |
| A USB-C cable (for the initial ADB setup, optional) | One-time setup. You can also do it entirely on-device if you prefer. | Free |
| ~2 hours of patience | QEMU's first boot takes 20–30 minutes under software emulation. There's no way around this. | Priceless |
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 computer
(Mac or Linux)
│
│ ssh
▼
╔══════════════════════════════════════════════════════╗
║ ANDROID PHONE (not rooted) ║
║ ║
║ ┌──────────────────────────────────────────────┐ ║
║ │ TERMUX (regular Android app) │ ║
║ │ │ ║
║ │ • qemu-system-aarch64 emulator │ ║
║ │ • debian.qcow2 disk image │ ║
║ │ • boot scripts auto-start VM │ ║
║ │ • sshd :8022 mgmt port │ ║
║ └──────────────────────┬───────────────────────┘ ║
║ │ launches ║
║ ▼ ║
║ ┌──────────────────────────────────────────────┐ ║
║ │ QEMU VM (real ARM64 hardware emulation) │ ║
║ │ │ ║
║ │ • Debian 12 (bookworm) │ ║
║ │ • real dockerd + Docker Compose v2 │ ║
║ │ • systemd (works, unlike proot) │ ║
║ │ • sshd :22 → forwarded to host :2222 │ ║
║ └──────────────────────────────────────────────┘ ║
╚══════════════════════════════════════════════════════╝
Two SSH targets (run from your computer):
ssh phone-termux → port 8022 → Termux shell
ssh phone-vm → port 2222 → Debian VM
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, and 192.168.0.9 to your phone's IP from Step 2.5.)
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 the plumbing works:
docker --context phone ps
# CONTAINER ID IMAGE COMMAND CREATED STATUS PORTS NAMES
# (empty list is fine — you haven't run anything yet)
docker --context phone run --rm hello-world
# → Hello from Docker!
If docker --context phone ps returns an empty table, you're connected. If it times out, the phone is offline, the IP changed, or QEMU died — re-check ssh phone-vm hostname works first.
8.2 The DOCKER_HOST gotcha (Mac users especially)
If you have Colima, Docker Desktop, or OrbStack installed on your Mac, you'll see this warning:
Warning: DOCKER_HOST environment variable overrides the active context.
This is normal. The --context phone flag does override DOCKER_HOST for that one command — the warning is just noise. Two ways to silence it:
Option A (recommended): Always pass --context phone explicitly. The warning appears but the command works.
Option B: Temporarily unset DOCKER_HOST for the session:
unset DOCKER_HOST
docker context use phone # now active
docker compose ps # no --context needed
8.3 Test with a real compose stack
Create a file on your computer called docker-compose.yml:
services:
whoami:
image: traefik/whoami
ports:
- "8080:80"
Save it anywhere — your desktop, home directory, wherever. The file lives on your Mac; the container will run on the phone.
Now bring it up:
cd /directory/where/you/saved/it
docker --context phone compose up -d
Expect this to take 5–15 minutes the first time. TCG emulation makes image pulls brutally slow. You'll see Pulling fs sit there for minutes at a time. That's normal — don't Ctrl-C.
When it finishes:
docker --context phone compose ps
# NAME IMAGE STATUS PORTS
# whoami-whoami-1 traefik/whoami Up 30 seconds 0.0.0.0:8080->80/tcp
# Test it (port 8080 is forwarded from the phone to your Mac in the QEMU launcher):
curl http://192.168.0.9:8080
# → displays the whoami container's response
Bring it down when done:
docker --context phone compose down
8.4 Common ways compose fails (and the fix)
"Cannot connect to the Docker daemon" / SSH timeout / "command exited with status 255"
The most common cause — your computer and the phone aren't on the same network. Verify Layer-2/3 reachability first:
# 1. Ping the phone (Mac/Linux)
ping -c 3 192.168.0.9
# If 100% packet loss → network problem, not Docker
# 2. If ping works, test the SSH port
nc -zv 192.168.0.9 2222
# If "Connection refused" or timeout → QEMU died, SSH isn't listening
# If "succeeded" → network is fine, Docker context should work
Network causes (in order of likelihood): different Wi-Fi, AP isolation enabled on router, guest network blocking client-to-client traffic, VPN on Mac, phone asleep and dropped off Wi-Fi.
Fix network: put both devices on the same SSID, disable AP isolation in router settings, turn off Mac VPN, wake the phone screen.
If network is fine but SSH still times out: QEMU died inside the phone. Restart it via phone-vm-start --wait if you have the helper scripts, or reboot the phone entirely and wait 20–30 min for TCG boot. Verify with ssh phone-vm hostname — should return docker-phone.
"pull access denied" or "TLS handshake timeout"
Image pull timed out. Two causes: (1) you skipped the /etc/docker/daemon.json config from Step 6.4, or (2) the image is large and TCG is just slow. Re-check the daemon config. For large images (>500 MB), expect 20+ minute pulls.
"no configuration file provided: not found"
You're not in a directory with a docker-compose.yml. The compose file lives on your Mac, not the phone. cd into the directory containing your docker-compose.yml first.
Volume mount path doesn't exist
If your compose file has volumes: - ./data:/data, the ./data path resolves on the phone, not your Mac. The phone has no ./data directory. Use absolute paths inside the VM: volumes: - /home/sulthon/myapp/data:/data, and create the directory on the phone first via ssh phone-vm mkdir -p /home/sulthon/myapp/data.
Port conflict on 8080
The QEMU launcher in Step 4 already forwards port 8080 from phone → Mac. If a container inside the VM also tries to bind 8080, it conflicts with the QEMU-level forward. Use a different port (8081, 3000, etc.) in your compose file.
8.5 (Optional) Make the phone the default context
unset DOCKER_HOST # required — otherwise DOCKER_HOST wins
docker context use phone
docker ps # now hits the phone
I don't recommend this. It's surprising when you forget and accidentally build an image on the phone over Wi-Fi. Keep --context phone explicit.
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.
⭐ The repo
All the code from this tutorial — the QEMU launcher, boot scripts, cloud-init templates, Docker daemon config, and 7 Mac/Linux helper scripts — lives in one place:
github.com/sulthonzh/android-docker-qemu ⭐
One-command setup:
curl -fsSL https://raw.githubusercontent.com/sulthonzh/android-docker-qemu/main/install.sh | bash
If this article saved you a weekend of trial and error, please star the repo. It helps other people find it — "no-root Docker on Android" is a common question, and most of the existing answers are bad. Stars are the only signal GitHub uses to surface projects in search.
Found a bug or have a question? Open an issue on the repo — that's a much better place for technical Q&A than article comments, because answers stay linked to the code.
Want to go deeper? The repo also includes PLAYBOOK.md — the full 1500-line engineering playbook with every pitfall I hit, how I diagnosed it, and how I fixed it. It's the document I wish I'd had when I started.
Happy hacking.
Top comments (0)