← all articles

Running uiautomator2 in Docker: Keep adb on the Host

This is my own setup, not a customer story. i run Android UI automation from a small Linux box in Singapore. the scripts use uiautomator2 (the Python wrapper around Google’s UI Automator), and for a long time they ran straight on the host with a pile of pip packages that drifted every time i touched the machine. i wanted the scripts in Docker so a rebuild would take minutes instead of an afternoon.

The question that sent me down this road is the one in uiautomator2 issue #931. as i read it, it is the usual one: the phone is plugged into the host, the script is inside a container, and the container can’t see the device. my answer is the one i settled on: don’t run adb inside the container and don’t pass USB through. leave one adb server on the host and point the container at it over TCP. the container carries python, uiautomator2 and your test code, nothing else.

The headline result is boring, which is what i wanted. the same image now runs on my Linux box and on a laptop, the host keeps full ownership of USB and authorisation prompts, and a container restart never kills the adb server that other jobs depend on. it took a few evenings and three real problems, covered below. everything here assumes devices you own or are authorised to test.

the setup

The stack, as of October 2026:

  • host: Ubuntu 24.04 mini PC, Docker Engine with the compose plugin, platform-tools installed on the host (not in the image)
  • devices: a handful of Android phones on a powered USB hub, USB debugging on, each authorised once on the host
  • image: python:3.12-slim, pip install uiautomator2, my own scripts copied in, no adb binary at all
  • orchestration: one compose file, one service per job type

Think single digits of phones per host. if you want to know where one host falls over, i wrote that up in host limits when running many android phones. USB power and hub quality hit well before CPU does.

Costs are mostly hardware i already had. the mini PC, hub and phones are sunk costs. the container layer costs nothing but time. my time was roughly three evenings, and most of that went on problems 1 and 2 below.

The design choice is the interesting part. there are three ways to give a container access to a phone:

  • run adb inside the container with the USB bus passed through (--privileged or --device /dev/bus/usb)
  • run adb inside the container and connect to phones over wireless adb
  • run adb on the host and let the container talk to that server

I picked the third. the first one works on Linux only, needs a privileged-ish container, and fights the host’s own adb server for the same devices. two adb servers on one machine will race for the USB interface, and you get devices flapping between offline and device. the second works but needs every phone on wifi with adb over TCP enabled, which is another thing to maintain. the third needs one setting and keeps adb where it already works. it is also the only option that behaves the same on Docker Desktop for Windows and macOS, where USB passthrough into containers is not a thing you want to depend on.

what worked

The adb server is a TCP service on port 5037, and adb clients are happy to talk to a remote one. that is how the adb documentation describes the client/server split: the client sends commands to the server, and the server talks to devices. the container only has to be a client. three things made it clean.

Option A, Linux host network mode. this is the shortest path. on a Linux host, network_mode: host makes the container share the host’s network stack, so 127.0.0.1:5037 inside the container is the host’s adb server. the Docker host network docs cover the semantics. the compose service is five lines:

services:
  runner:
    build: .
    network_mode: host
    environment:
      ANDROID_ADB_SERVER_HOST: 127.0.0.1

No port mappings, no bridge, nothing to forward. this is what i run for the jobs that only need the phones.

Option B, bridge network with extra_hosts. when i want the container isolated (it also talks to a database, say), i keep the default bridge and add the host as a named address:

services:
  runner:
    build: .
    extra_hosts:
      - "host.docker.internal:host-gateway"
    environment:
      ANDROID_ADB_SERVER_HOST: host.docker.internal
      ANDROID_ADB_SERVER_PORT: "5037"

host-gateway is a special value Docker resolves to the host’s address on the bridge, and it is documented under extra_hosts in the compose reference. On Docker Desktop, host.docker.internal already resolves by itself, so the line is harmless there.

The host side needs one change for option B. by default the adb server only listens on loopback, so the bridge can’t reach it. start it listening on all interfaces:

adb kill-server
adb -a nodaemon server start

Run that under systemd or in a tmux pane. the -a flag is what opens it up.

The third tactic is not shipping adb at all. uiautomator2 talks to the device through the adb server using a Python adb client (adbutils), which honours the ANDROID_ADB_SERVER_HOST and ANDROID_ADB_SERVER_PORT environment variables. so in the container, a plain u2.connect("SERIAL") just works once those two variables are set. check the adbutils version your uiautomator2 release pins if it ever misbehaves, because that is where the env handling lives. the image dropped from “python plus android-tools plus udev rules” to python plus one pip package, and that is where most of the maintenance saving came from.

The fourth is pinning serials. i pass the device serial as an env var per service rather than letting scripts grab “the first device”. with more than one phone attached, “first device” is a coin flip after every reconnect.

what broke

Problem 1, the bridge could not reach the adb server. i set ANDROID_ADB_SERVER_HOST=host.docker.internal and got connection refused. the container was fine. the host adb server was bound to loopback only, so traffic arriving from the bridge address was rejected. the fix was the -a flag above. one more thing to know: the adb server has no authentication. anyone who can reach port 5037 can drive your phones. so on a host with a LAN address, i block 5037 from everywhere except the Docker bridge subnet with a firewall rule. if you only do option A, you never have to open it up at all, which is a real argument for host network mode.

Problem 2, the script connected but then hung on setup. uiautomator2 installs and starts a small agent on the phone, and depending on the version, the Python side reaches it either through an adb port forward or through the adb connection directly. a forward binds a port on the machine running the adb server, which is the host, not the container. in bridge mode that meant the container was trying to reach a forwarded port on its own loopback and getting nothing. what i did:

  • on Linux, switch that service to network_mode: host, which removes the whole class of problem because the container’s loopback is the host’s
  • on Docker Desktop, upgrade uiautomator2 and test again before assuming anything, and read the release notes for how that version reaches the agent
  • if you must stay in bridge mode and your version forwards ports, publish the forward range or use host mode for that service

This is the part of issue #931 i would watch for. “adb devices works in the container” is necessary and not sufficient. the second hop, from your script to the agent on the phone, has to be reachable too.

Problem 3, the phone said “unauthorized” after a rebuild. i had briefly tried the “adb inside the container” route and the container generated its own adb key, so the phone prompted for a new fingerprint and my headless box had nobody to tap Allow. going back to the host adb server fixed it, because the host key was already trusted. it is a good reminder that adb authorisation belongs to whichever machine runs the server. if a phone won’t let you enable USB debugging at all, that is a separate problem, and i covered it in fixing a greyed out usb debugging toggle on android.

the numbers

I won’t invent benchmarks here. what i can give you is arithmetic you can check against your own setup, with my assumptions labelled.

Image size and rebuild time are the visible wins, but i did not measure them rigorously, so treat the direction as the point: dropping the adb binary and udev rules removes layers, and the build is just a Python base image plus a pip install.

The per-host maths is simple. one adb server serves every container on the machine. so adding a job type is a new compose service, not a new adb stack:

  • 1 host adb server, any number of runner containers
  • 1 phone is driven by one job at a time, so serials map to services one-to-one
  • the real ceiling is USB power and hub quality, not Docker

On cost, the container layer adds nothing to the monthly bill. if you rent a VPS to host the runner, plan on the VPS price and nothing else, but a VPS has no USB, so the phones must live elsewhere and you are back to wireless adb or a tunnel. that is the main reason i keep the runner on the same box as the phones.

lessons

  • leave adb on the host. one adb server per machine, and containers are clients. this avoids USB passthrough, key churn and the two-servers-fighting problem.
  • try host network mode first on Linux. it removes the extra hop, the firewall question and the port-forward problem in one line.
  • if you use the bridge, bind the adb server deliberately and firewall it. adb -a with no firewall means anyone on your LAN can control your phones.
  • set the adb host through environment variables, not code. the same image then runs on host mode, bridge mode or a remote adb server with no rebuild.
  • pin device serials per service. never rely on “the first device”.
  • test the second hop. a working adb devices doesn’t prove the script can reach the on-device agent. run a one-line d.info from inside the container as your health check.
  • know what your tests need on the device. whether you need root is a separate question from where your scripts run, and root vs non-root android automation is the place to settle it. likewise, if your selectors are what you are worried about, read is uiselector deprecated in android’s ui automator before you build on them.

would i do it again

Yes, with one caveat. the container is a packaging convenience, not a device-access solution. the hard part is never Python in Docker. it is the boundary between the container and wherever adb runs. once i accepted that adb lives on the host, the rest was ordinary compose work.

The caveat is Docker Desktop. on Windows and macOS the bridge route works, but host networking behaves differently and i trust it less. if your whole workflow lives on a Mac or Windows laptop, test the second-hop problem early, before you build a lot on top of it.

If you do not own phones, or don’t want a USB hub on your desk, renting hardware is the other route. cloudf.one rents real Android phones in Singapore on dedicated hardware, each with a persistent Singapore mobile IP, controlled from the browser. there is no self-service adb. adb and Appium reach a rented phone only through a WireGuard tunnel that we set up on request (email [email protected]). it is Singapore-only, so it only makes sense if you need phones in that location. and as always, automate only apps you own or are authorised to test.

For more write-ups like this one, the full list is at /blog/.

Written by Xavier Fok

disclosure: this article may contain affiliate links. if you buy through them we may earn a commission at no extra cost to you. verdicts are independent of payouts. last reviewed by Xavier Fok on 2026-10-12.

free download
The adb and scrcpy command cheat sheet

The commands we reach for on a rack of real phones, on one printable page. Leave your email and we will also tell you when we publish a new guide, a few times a month at most.

from the team behind cloudf.one
Passes on the emulator, fails on a real phone?

cloudf.one rents real Android phones in Singapore that you control from the browser, each on a persistent Singapore mobile IP. Real hardware, a real OEM build and a real carrier network, without buying and racking the phones yourself.

test on a real phone →
read on
More from ADB Handbook

adb commands, scrcpy flags, automation frameworks, and why a test passes on one phone and fails on the next.

browse all articles →