DEV Community

Sulthon Zainul Habib
Sulthon Zainul Habib

Posted on

Run Real Docker on Android — No Root, No Tricks, Just QEMU

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 context so docker --context phone compose up works 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.


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 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
Enter fullscreen mode Exit fullscreen mode

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:

  1. proot-distro has no systemd. No systemd means no systemctl enable docker. You'd have to manually start the daemon on every login.
  2. proot-distro has no cgroups. No cgroups means no container resource control. Docker will refuse to start.
  3. 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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

If you don't have one, generate it:

ssh-keygen -t ed25519 -C "your-email@example.com"
# Press Enter through all the prompts
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

Replace PASTE_YOUR_PUBLIC_KEY_HERE with the key you copied. Keep the single quotes around it.

2.4 Start the SSH server

sshd
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

Now test:

ssh phone-termux whoami
# → u0_a892
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

Build the seed ISO:

genisoimage -output seed.iso -volid cidata -joliet -rock user-data meta-data
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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=on is the UEFI firmware code (read-only).
  • The second -drive if=pflash is the UEFI vars file (writable — this is why we made the copy earlier).
  • The -drive file=$IMG,if=virtio is your Debian disk.
  • The -drive file=$SEED,if=virtio,readonly=on is 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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

When you see something like:

[ OK ] Started OpenSSH server
Enter fullscreen mode Exit fullscreen mode

…and the log stops growing, the VM is ready. Verify from your computer:

ssh -p 2222 sulthon@192.168.0.9 hostname
# → docker-phone
Enter fullscreen mode Exit fullscreen mode

(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
Enter fullscreen mode Exit fullscreen mode

Now ssh phone-vm Just Works.


Step 6: Install Docker inside the VM

SSH into the VM and install everything:

ssh phone-vm
Enter fullscreen mode Exit fullscreen mode

6.1 Install Docker

sudo apt-get update
sudo apt-get install -y docker.io
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

You'll need to log out and back in for this to take effect:

exit  # back to your computer
ssh phone-vm  # back in
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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: false and ipv6: 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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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.
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

7.3 Test it (optional but recommended)

Reboot your phone the normal Android way. After it restarts:

  1. Wait ~5 minutes for Android to fully boot.
  2. Wait another ~20 minutes for QEMU to cold-boot Debian.
  3. From your computer: ssh phone-vm hostname should return docker-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"
Enter fullscreen mode Exit fullscreen mode

(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
Enter fullscreen mode Exit fullscreen mode

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!
Enter fullscreen mode Exit fullscreen mode

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.
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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"
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

Bring it down when done:

docker --context phone compose down
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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:

  1. Keep the phone plugged in. Battery-saver mode is brutal.
  2. Keep termux-wake-lock active. Run ssh phone-termux termux-wake-lock to verify.
  3. 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).
  4. 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 build works 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.

Top comments (0)