Self-Hosted Mobile Devices
Tier 2 — runs mobile tests on Android emulator pods that you operate in your own K8s cluster. Best balance of cost + control for sustained CI usage, and the strongest "data stays in our infra" posture.
The full T2 stack ships as of DF.2.c:
- DF.2.a — backend
mobile_deviceregistry - DF.2.b — agent
SelfHostedProvider(dispatch + release) - DF.2.c —
android-emulator-podDocker image + Helm chart
See helm/android-emulator-pod for the chart and docker/android-emulator-pod for the image source.
Quick install
# 1. Store the agent token (the same one mobile-test-agent uses)
kubectl create secret generic emulator-agent-token \
--from-literal=agent-token=$AGENT_TOKEN -n proofarc
# 2. Install the chart
helm install emulators ./helm/android-emulator-pod \
--namespace proofarc \
--set backend.url=http://proofarc-backend.proofarc.svc.cluster.local:8080 \
--set backend.existingSecret=emulator-agent-token \
--set replicaCount=4
# 3. Confirm registration
curl -H "Authorization: Bearer $TOKEN" http://localhost:8080/api/mobile-devices
After ~60-90s of cold start per pod, four AVAILABLE devices appear in the registry. The agent's SelfHostedProvider will pick them up at job time.
Full chart docs: helm/android-emulator-pod/README.md.
Architecture
┌──────────────────────────── Customer K8s cluster ────────────────────────────┐
│ │
│ ┌────────────────┐ poll jobs ┌──────────────────┐ WebDriver │
│ │ Backend API │◄──────────────►│ mobile-test- │ ────────────┐ │
│ │ (Spring Boot) │ │ agent (Java) │ │ │
│ │ │ register └──────────────────┘ │ │
│ │ mobile_device │◄─────┐ │ │
│ │ registry table │ │ ▼ │
│ └────────────────┘ │ ┌────────────────────┐ │
│ │ POST /register on │ android-emulator- │ │
│ │ startup, heartbeat │ pod (KVM node) │ │
│ │ every 30s │ ┌────────────────┐ │ │
│ └──────────────────────────┤ │ Appium :4723 │ │ │
│ │ ├────────────────┤ │ │
│ │ │ AVD │ │ │
│ │ │ Pixel_7_API_34 │ │ │
│ │ └────────────────┘ │ │
│ └────────────────────┘ │
│ (1 pod per device, │
│ scale horizontally) │
└──────────────────────────────────────────────────────────────────────────────┘
Cluster prerequisites
Self-hosted Android emulators require nested virtualization (KVM) on the K8s nodes. Without it, the emulator can boot but runs in software-emulation mode — orders of magnitude slower.
| Cloud | KVM-capable instances |
|---|---|
| AWS | Bare-metal instances (c5.metal, i3.metal, c5n.metal). Most non-metal families don't support nested virt for KVM passthrough. |
| GCP | Most n2/n2d families when created with --enable-nested-virtualization. |
| Azure | Dv3 / Ev3 / Dv5 / Ev5 families support nested virt out of the box. |
| On-prem bare metal | Just works. |
You also need /dev/kvm exposed to the emulator pod — either privileged mode or the KVM device plugin.
How the registry works
Every emulator pod is a row in the mobile_device table. The lifecycle:
pod startup
│
▼ POST /api/agent/mobile-devices/register
[ AVAILABLE ]──────┐
▲ │ POST /pick-available (atomic AVAILABLE → BUSY)
│ ▼
│ [ BUSY ]
│ │ POST /{id}/release (after test)
└───────────────┘
│
│ no heartbeat for N minutes → admin marks offline
▼
[ OFFLINE ]
│
▼ heartbeat resumes → automatic recovery
[ AVAILABLE ]
Concurrency is safe: if two agent jobs race for the same device, optimistic locking in the registry ensures only one wins; the other automatically retries with the next candidate.
API reference
Pod self-registration
POST /api/agent/mobile-devices/register
{
"name": "android-emulator-prod-01",
"agentId": "mobile-test-agent",
"platform": "ANDROID",
"platformVersion": "13.0",
"deviceName": "Pixel_7_API_34",
"appiumUrl": "http://android-emulator-prod-01.proofarc.svc.cluster.local:4723",
"capabilities": {
"appium:autoGrantPermissions": true
}
}
Idempotent — re-registering with the same (agentId, name) updates the existing row and resets status to AVAILABLE. Useful after pod restarts.
Pick an available device (agent)
POST /api/agent/mobile-devices/pick-available?platform=ANDROID&platformVersion=13.0&jobId=test-job-1
Returns the picked device (now BUSY):
{
"id": 7,
"name": "android-emulator-prod-01",
"appiumUrl": "http://android-emulator-prod-01.proofarc.svc.cluster.local:4723",
"deviceName": "Pixel_7_API_34",
"automationName": "UiAutomator2",
"status": "BUSY",
"lastJobId": "test-job-1"
}
Returns 404 when no AVAILABLE device matches the requested platform/version.
Release after test
POST /api/agent/mobile-devices/{id}/release — flips BUSY → AVAILABLE. Idempotent.
Heartbeat
POST /api/agent/mobile-devices/{id}/heartbeat — pods should call this every ~30s. Recovers OFFLINE → AVAILABLE automatically when a previously-dead pod comes back.
Admin operations
GET /api/mobile-devices list all (filter: ?status=...)
GET /api/mobile-devices/{id} device detail
POST /api/mobile-devices/{id}/mark-offline force OFFLINE (ADMIN)
DELETE /api/mobile-devices/{id} unregister stale row (ADMIN)
Configuring a mobile app for T2
{
"cloudProvider": "SELF_HOSTED",
"cloudConfig": {
"platformVersion": "13.0"
}
}
That's it. The agent will:
- Call
/pick-available?platform=ANDROID&platformVersion=13.0 - Get back a device with its
appiumUrl - Connect the WebDriver session to that URL
- Run the test
- Call
/{id}/releasewhen done
If no device is available, the agent surfaces the error in Job History — operator can either spin up more pods or fall back to a vendor farm.
Capability matching
v1 supports matching on:
platform(ANDROID / IOS — IOS not yet implemented for self-hosted)platformVersion(optional — exact match if specified, any version if omitted)
Future versions can extend the match with more capabilities (device_name, model, screen size). Today: if you need different device models, register them as separate mobile_device rows and add filtering logic in your test or wrapper code.
Pod image (coming soon — DF.2.c)
The android-emulator-pod image will bundle:
- Android SDK + a pinned AVD (default:
Pixel_7_API_34) - Appium server with the uiautomator2 driver
- A small registration sidecar (Go or shell) that POSTs to
/registeron startup and sends/heartbeatevery 30s - A graceful-shutdown handler that posts to
/releaseon SIGTERM
Until that image lands, you can roll your own using budtmo/docker-android plus a startup script that calls the registration endpoint with the right metadata.
Cost sanity check
| Setup | Approx monthly cost (1 instance, 100h/month) |
|---|---|
1× AWS c5.metal (96 vCPU, 192 GB RAM) | ~$500 — hosts ~4-8 emulator pods comfortably |
1× GCP n2-standard-32 with nested virt | ~$700 — hosts ~3-5 pods |
| Equivalent BrowserStack (1 parallel) | ~$200/mo, no setup, no ops |
T2 wins when:
- You run > 3 hours/day of test execution
- You can't send APKs / test data outside your infra (regulatory)
- You want predictable cost regardless of test volume
T4 (BrowserStack/Sauce) wins when:
- You need iOS without buying Macs
- You don't want to operate K8s with KVM nodes
- Your test volume is bursty
Many teams use both — T2 for steady CI, T4 for release-day device matrix.
Real devices (T3, future)
Real-device farms (OpenSTF / DeviceFarmer) use the same mobile_device registry shape. Each physical device registers with its USB serial number in capabilities. Lower priority since most teams don't want to operate device hardware — but the data model accommodates it.
What's NOT in the registry yet
- iOS support — Android-only for now. iOS Simulator requires macOS hardware (EC2 Mac for self-hosted iOS, or use T4 BrowserStack/Sauce).
- Automatic OFFLINE sweeper — admin manually marks dead devices via
mark-offlineendpoint. Cron-based stale-device sweep can come later. - Pod image — DF.2.c will ship the canonical
android-emulator-podDocker image + Helm chart.
Next steps
- Overview — tier comparison
- Device farms — T4 alternative
- Credentials — vault setup (less relevant for T2; emulators don't need vendor keys)