Proxy your Android emulator and iOS simulator to your local Docker stack

One command handles the certificate and the routing. The interesting part is why it takes fourteen steps by hand.

TL;DR: Point the device at an HTTPS proxy running on your Mac. The proxy does the DNS lookup, so your Mac’s /etc/hosts becomes the single source of truth for routing. The device only needs to trust one certificate, once. If you want to skip ahead: uvx proxy-lab start android.

GitHub – kibotu/proxy-lab.sh: Android’s two HTTPS blockers – device trust and proxy routing – scripted end to end, with pre-flight checks that catch the usual traps first. Pinned, CI-tested, one YAML for both platforms.

Image generated by Gemini.

The setup you already have

Docker Compose on your Mac. A reverse proxy (Traefik, nginx, Caddy, pick your favorite) listens on 443 and routes by hostname:

mysub.domain.com  ->  api container
auth.domain.com -> keycloak container

Your /etc/hosts has the usual line:

127.0.0.1  mysub.domain.com auth.domain.com

Safari opens https://mysub.domain.com and the whole stack works. Nice. So you build the debug app against the same URL, hit run, and get this:

java.net.UnknownHostException: Unable to resolve host "mysub.domain.com"

Or something more exciting: it resolves, because the domain is real and public, and your integration test just wrote a row into production.

Both are fixable with one idea. The rest of this article is why that idea needs a certificate.

The emulator is a different computer

That is the whole thing in one sentence. The Android emulator runs its own kernel with its own network stack and its own resolver, and it has never heard of your /etc/hosts. Neither has the iOS simulator, though for a nicer reason we will get to.

The first instinct is to give the device the same hosts file. On Android that means /system/etc/hosts, a writable system image, and -writable-system on every boot. It works, and you can feel the shape of the problem: the same mapping now lives in two places, and the second one resets itself at the worst time.

The second instinct is to change your base URL to http://10.0.2.2:8080. That address is useful, it is the host machine as seen from the emulator (docs), and for a single service it is the right answer. For a hostname-routed stack it costs more than it saves:

  • Your reverse proxy routes on the Host header. Send it 10.0.2.2 and Traefik cannot tell which container you meant.
  • TLS stops matching, because the certificate is for mysub.domain.com and you asked for an IP.

Cookies scoped to .domain.com stop being set, and your debug config now differs from production in exactly the places that hide bugs until release day. What you want is for the same URL to mean something different on your machine. That is a DNS question, and one computer here already knows the right answer.

A proxy moves the DNS lookup to the right computer

Here is the part that took me embarrassingly long to internalize. When an HTTP client talks through a proxy, it does not resolve the hostname first. It hands the name over:

CONNECT mysub.domain.com:443 HTTP/1.1

The proxy resolves it. The proxy runs on your Mac. Your Mac’s /etc/hosts says 127.0.0.1. Your Docker reverse proxy is sitting on 127.0.0.1:443 and sees a real Host header and real SNI, so it routes correctly.

Nothing on the device needs to know anything. Same URL, same cookies, same certificate name, same code path as production. Stop the proxy and the device goes straight back to the real internet. That is a lot of value from one setting:

adb shell settings put global http_proxy 10.0.2.2:8080

Note that the setting is device-wide, not app-scoped. Every app on that emulator now goes through the proxy, which matters in a minute.

One certificate to install, and a good reason why

To see and route HTTPS, the proxy terminates TLS. It presents its own certificate, signed by a CA generated on first run at ~/.mitmproxy/mitmproxy-ca-cert.pem. To your app that CA is a stranger, and your app is right to hang up on it.

The side effect is worth the trouble. mitmproxy talks to your Docker stack with verification relaxed (–set ssl_insecure=true), so your self-signed local certificates never need to be trusted by anything. One CA on the device covers every domain in your compose file, today and next month when you add three more. You install one certificate, once, and stop thinking about local TLS.

You do have to actually install it, and that part deserves some context.

Why Android asks for an explicit opt-in

Since Android 7, apps targeting API 24 and above do not trust user-installed CAs by default (Android Developers Blog). This was the right call. A CA that any app or any well-meaning user can add is a CA an attacker can add, and most users cannot reasonably evaluate that prompt.

The old workaround was the system store, which the mitmproxy guide still documents: hash the cert by hand, remount /system, reboot. Android 14 then moved that store into the Conscrypt APEX (AOSP), which is a real improvement, because root certificates now ship as Mainline updates instead of waiting for a full OS release. APEX modules are immutable and /apex is mounted with private propagation, so editing the system store is closed even for root. Tim Perry wrote the definitive walkthrough, and what remains is a Magisk module or nsenter into Zygote’s mount namespace. Impressive engineering, and more than most of us need to look at our own JSON.

Google left a proper door for exactly this case, and it has been there since Nougat.

The supported path: debug-overrides

Apps can opt in to the user trust store per build type with a network security config. Put this in your debug source set as res/xml/network_security_config.xml:

<?xml version="1.0" encoding="utf-8"?>
<network-security-config>
<debug-overrides>
<trust-anchors>
<certificates src="user" />
</trust-anchors>
</debug-overrides>
</network-security-config>

Reference it from the <application> tag in your manifest. <debug-overrides> only applies when the build is debuggable, so it cannot weaken a release build even if it ships (docs). Keep it in the debug source set anyway, because reviewers should not have to know that rule by heart.

Supported, survives OS upgrades, no Magisk, no writable system image. Your app now trusts the user store.

The certificate still has to get in there, and that store is not the one in Settings. It lives at /data/misc/user/0/cacerts-added/ and it has opinions:

  1. The filename must be the certificate’s subject hash plus .0. It is subject_hash_old, the OpenSSL 1.0 algorithm, not subject_hash.
  2. Mode 644, then restorecon, so the SELinux label is right.
  3. You need adb root, which means a Google APIs system image. Play Store images decline with adbd cannot run as root in production builds, which is accurate and says nothing about images.
  4. Reboot, then adb root again, because adbd returns to the shell user after a reboot and the shell user cannot read that directory.

Each of those four can go wrong without crashing or logging anything. You get ERR_CERT_AUTHORITY_INVALID and no hint about which one it was. Once they are all right, the certificate survives reboots and you never touch it again for that AVD.

iOS gets to skip most of this

The simulator has no network stack of its own. It borrows the Mac’s, which is why 127.0.0.1 already works and why DNS is not a topic here.

That also means it has no proxy setting of its own. Either set the proxy for the whole Mac in System Settings > Network > (interface) > Details > Proxies, or set connectionProxyDictionary on your URLSessionConfiguration for just your app. Then, once per simulator: open mitm.it in Safari (it is served by the proxy, so start the proxy first), install the profile under VPN & Device Management, and flip the switch under General > About > Certificate Trust Settings.

Three steps, no root, no reboot. Apple gets real credit for that one.

Count the steps, then stop doing them by hand

For one Android emulator, from cold:

Install mitmproxy at a known version. Generate the host CA. Boot an AVD, and make sure it is a Google APIs image. adb root. Hash the cert with the right algorithm. Create the directory with the right mode. Push the file under the right name. chmod and restorecon. Reboot. Wait for sys.boot_completed. adb root again. Check nothing else is on port 8080. Set the global proxy. Start the proxy. Clear the global proxy on exit, or the device keeps pointing at a socket that is gone.

Fourteen steps, a handful of quiet failure modes, all of it mechanical. Mechanical is good news, because that is what scripts are for.

It is also worth pricing out if you lead a team. Those steps are the difference between a new hire seeing their first intercepted request on day one or on day three, and between everyone’s emulator behaving the same or each one being quietly personal. A pinned mitmproxy version means a bug you reproduce is a bug they reproduce.

So I put it in a script

proxy-lab.sh runs those steps and checks the preconditions before it touches anything:

uvx proxy-lab.sh proxy-lab start android

First run on a cold machine:

✓ tools       adb, uv, openssl, lsof
✓ host CA ~/.mitmproxy/mitmproxy-ca-cert.pem
… emulator booting Pixel_9_API_35
✓ emulator Pixel_9_API_35 booted in 38s
… device CA installing c8750f0d.0, one reboot, once per AVD
✓ device CA c8750f0d.0 trusted
✓ port 8080 free
✓ proxy 10.0.2.2:8080 set
✓ mitmdump 0.0.0.0:8080, Ctrl-C to stop
[local_router] https://mysub.domain.com/v1/session
[local_router] https://auth.domain.com/realms/dev/protocol/openid-connect/token

The second run on the same AVD skips to the last two lines. uv pins mitmproxy to one version, so the whole team sees identical behavior and nobody installs Python.

Add the hostnames from your compose file to a YAML list and they get tagged in the output, which matters once your app is making two hundred requests a minute:

domains:
- ".domain.com"
uvx proxy-lab.sh proxy-lab start android my-domains.yml

Pin the tag, put that line in your Makefile, and commit the domains file next to it. Onboarding becomes one command that behaves the same on every machine. iOS is the same command with ios, and a much thinner wrapper, for all the reasons above.

What it touches

Reasonable question before running a git URL that roots your emulator. The Android script is 279 lines of bash and the iOS one is 38, so you can read both in a sitting, and you should. What they do:

  • Nothing is installed globally. uv runs mitmproxy from its own cache at a pinned version. Clear the cache and it is gone.
  • The CA goes into the emulator’s user store. Only builds that opted in through debug-overrides care that it exists.
  • The port check stops stale mitmdump processes from earlier runs and refuses to touch anything else. Your dev server on 8080 gets an error message, not a signal.
  • Ctrl-C clears the device proxy setting. If you kill -9 instead, the next run cleans up after you.
  • The emulator keeps running after the proxy stops. The script boots it, it does not own it.

The failure messages got more attention than the automation did. Missing tool, Play Store image, busy port, wrong AVD name: each one prints the fix instead of a stack trace.

The edges worth knowing

Being upfront about these, because you will meet them anyway:

  • Not every HTTP client uses the system proxy. OkHttp and URLSession do, so stock Android and iOS work out of the box. Dart’s HttpClient needs findProxy, and Cronet needs its own configuration. If traffic is missing, check the client before you suspect the certificate.
  • The proxy setting is device-wide. Other apps on the emulator route through it too, and the ones that never opted into the user store will fail TLS while it runs. Same reason Android may show a “No internet connection” banner: the connectivity probe does not use user CAs. Your app is unaffected.
  • Certificate pinning wins, as designed. A pinning app will reject the proxy CA. Turn pinning off in debug builds.
  • Cleartext still needs opting in. Plain HTTP locally means usesCleartextTraffic on Android and an ATS exception on iOS. Or serve TLS locally, which you already are if you followed along.
  • Emulator and simulator only. Physical devices need root for the user store, and that is a different article by someone who knows more about it than I do.

How do you handle this?

This is not the only sane approach and I would like to hear the others. Some teams run a DNS server inside the compose stack and point the emulator at it. Some register a real domain whose A record is 127.0.0.1 and take certificates from Let’s Encrypt, which sidesteps the CA question for about twelve dollars a year. Some use HTTP Toolkit, which automates the hard Android 14 path properly and comes with a UI.

If you have a cleaner answer, or if the script trips on your setup, the issues are open and real failure reports are the most useful thing anyone can send me. Paste the exact error line. The scripts are built to fail loudly, so that line is usually the whole diagnosis.

And if this saved you an afternoon, a coffee is a nice way to say so. Entirely optional. The bug reports are worth more.


Proxy your Android emulator and iOS simulator to your local Docker stack was originally published in ProAndroidDev on Medium, where people are continuing the conversation by highlighting and responding to this story.