Self-Hosting
This page is for maintainers. It explains how the whole system fits together and exactly how to bring it up on a fresh Mac (e.g. after buying a new machine).
1. Big picture
The orchestrator (testlab-test-orchestrator, a Spring Boot app) receives
test requests over HTTP (POST /api/v1/startTest), runs them on the physical
devices, builds a report, uploads it to Google Drive and emails the result.
Test devices are connected by USB to the Mac. There are two execution worlds:
| Platform | How it runs | Where the orchestrator runs |
|---|---|---|
| Android / Flutter / React Native | adb + flutter |
Docker container |
| iOS | xcodebuild (Xcode) |
macOS host (cannot be containerized) |
So in normal operation there are two orchestrator instances:
- A Docker instance for Android/Flutter/RN (
IOS_ENABLED=false). - A host-native instance for iOS (
IOS_ENABLED=true), run directly on macOS.
GitLab CI / clients ──HTTP──► orchestrator
│
Android/Flutter/RN ──────────►│ (Docker) ──adb over TCP──► host adb server ──USB──► phones
iOS (xcuitest) ──────────►│ (host) ──xcodebuild────────────────────────────► iPhones
2. Why Docker can't see USB (and how we solve it)
Docker Desktop on macOS runs containers inside a hidden Linux VM. That VM does not get the Mac's USB devices — there is no USB passthrough on macOS.
We don't need USB inside the container. adb is split into a server (owns the
USB devices) and a client (sends commands); they talk over TCP. So:
- The adb server runs on the Mac (it has USB access).
- The container only runs the adb client, which connects to the Mac's server
over the network. Device data flows:
container → TCP → host adb server → USB → phone.
The Mac's adb server only listens on 127.0.0.1, which the container can't reach,
so a tiny forwarder (socat) listens on :5038 and relays to 127.0.0.1:5037.
The container reaches the Mac via the special name host.docker.internal.
amd64 / Rosetta: Google ships adb/aapt for Linux as x86_64 only. On
Apple Silicon the image therefore runs as linux/amd64 (via Rosetta) — set with
platform: linux/amd64 in docker-compose.yml.
Why not iOS in Docker: iOS testing needs Xcode, which only runs on macOS. A Linux container can't run Xcode, so iOS stays on the host regardless of USB.
3. New-Mac setup (step by step)
3.1 Base tools (Homebrew)
# Homebrew (if not present): https://brew.sh
brew install --cask docker # Docker Desktop
brew install --cask temurin@21 # JDK 21 (host-native orchestrator / builds)
brew install socat # adb TCP bridge
brew install xctesthtmlreport # iOS xcuitest HTML report (host)
brew install cocoapods # iOS Flutter builds (host)
- Android SDK + adb: install Android Studio (or
brew install --cask android-commandlinetools) andplatform-tools.adbends up at/opt/homebrew/bin/adb(symlink) or~/Library/Android/sdk/platform-tools/adb. - Flutter: install the Flutter SDK (host-side only needed for iOS Flutter; the Docker image bundles its own Flutter for Android Flutter tests).
- Xcode: install from the App Store, then
xcode-select --installand open it once to accept the license. Required for iOS.
3.2 Docker Desktop settings
- Settings → General → Start Docker Desktop when you log in ✅
- Settings → General → Use Rosetta for x86/amd64 emulation ✅ (Apple Silicon)
- Settings → Resources → File sharing: ensure the repo path is shared.
3.3 macOS auto-login
So the adb bridge (a launchd agent) and Docker start after a reboot without someone logging in manually:
- System Settings → Users & Groups → Automatically log in as the
testlabuser.
3.4 Connect & authorize devices
- Plug in the devices over USB. On each device accept "Trust this computer" (tick "Always allow").
- Enable Developer options and USB debugging. On some devices also enable "Disable permission monitoring" (Developer options) for screenshot tests.
- For iOS devices: enable Developer Mode (Settings → Privacy & Security → Developer Mode → reboot) and keep them unlocked during runs.
- Verify:
adb devices -lshows each Android device asdevice(notunauthorized).
4. The adb bridge (launchd, foolproof)
This keeps the adb server up and exposes it to Docker on :5038, surviving
reboots and device unplug/replug. Files live in the orchestrator repo under
scripts/ and deploy/.
# 1. Install the bridge script OUTSIDE ~/Desktop (macOS TCP/TCC blocks launchd
# from executing files in Desktop/Documents/Downloads).
mkdir -p /Users/Shared/testlab
cp scripts/adb-bridge.sh /Users/Shared/testlab/adb-bridge.sh
chmod +x /Users/Shared/testlab/adb-bridge.sh
# 2. Install and load the LaunchAgent (auto-start + auto-restart).
cp deploy/ee.taltech.testlab.adb-bridge.plist ~/Library/LaunchAgents/
launchctl load -w ~/Library/LaunchAgents/ee.taltech.testlab.adb-bridge.plist
# 3. Verify
launchctl list | grep adb-bridge # should show a PID and status 0
lsof -nP -iTCP:5038 -sTCP:LISTEN # socat should be LISTENing
What it does: a background loop runs adb start-server every 20s (idempotent;
restarts the server if anything kills it or a device is replugged), and socat
forwards :5038 → 127.0.0.1:5037. KeepAlive/RunAtLoad in the plist restart
it on crash and at login.
5. Orchestrator in Docker (Android / Flutter / RN)
In testlab-test-orchestrator/:
# Secrets: place the Google Drive OAuth client at secrets/credentials.json.
# (The container writes its StoredCredential token cache next to it, so the
# secrets/ mount must be read-write.)
export GIT_TOKEN=glpat-xxxxxxxxxxxxxxxx # token used to clone student repos
docker compose up -d --build
Key points (already configured in docker-compose.yml):
platform: linux/amd64(Rosetta).ADB_SERVER_SOCKET=tcp:host.docker.internal:5038→ the adb bridge.IOS_ENABLED=false→ rejects iOS jobs cleanly.- Volumes:
./output(reports/screenshots) and./secrets(read-write). restart: unless-stopped→ comes back with Docker after a reboot.
Verify:
docker compose exec orchestrator adb devices # should list the host's devices
curl -s -X POST http://localhost:8080/api/v1/startTest \
-F platform=android -F reportType=screenshot \
-F apk=@app-debug.apk -F email=you@taltech.ee -F repositoryName=test
See testlab-test-orchestrator/DOCKER.md for more detail.
5b. Docker Desktop container grouping
All lab containers are presented as one group named testlab in Docker
Desktop, instead of one group per repository.
How it works. Docker Desktop groups containers solely by the
com.docker.compose.project label, and the view is flat (no nested
sub-groups). So every lab stack uses the same Compose project name:
- Compose-based stacks set
name: testlabat the top of theirdocker-compose.yml(testlab-proxy,testlab-android-device-portal,testlab-test-orchestrator). - The plain
docker runsites add the label explicitly in their CI:docker run ... --label com.docker.compose.project=testlab --label com.docker.compose.service=<name> ...(testlab-documentation,testlab-projects-documentation,testlab-results-portal).
Readable sub-grouping by naming. Because Docker Desktop sorts services alphabetically within the group, the multi-service stacks use prefixed service names so related containers cluster together (the closest thing to nesting that Docker Desktop allows):
testlab
├─ android-device-portal-rethinkdb
├─ android-device-portal-stf
├─ proxy-certbot
├─ proxy-cron
├─ proxy-nginx
├─ proxy-oauth2
├─ testlab-documentation
├─ testlab-projects-documentation
├─ testlab-results-portal
└─ testlab-test-orchestrator
Network aliases keep things working after a rename. Two services are reached by their original name on the Docker network, so when they were renamed they kept that name as an alias:
| Service (renamed) | Network alias | Used by |
|---|---|---|
android-device-portal-rethinkdb |
rethinkdb |
STF → tcp://rethinkdb:28015 |
proxy-oauth2 |
oauth2-proxy |
nginx → proxy_pass http://oauth2-proxy:4180 |
Data was preserved across the project rename by: pinning the RethinkDB volume
to its original name (rethinkdb-data: { name: testlab-android-device-portal_rethinkdb-data }),
and keeping the proxy's nginx_conf / letsencrypt_certs volumes external: true.
Trade-offs of one shared project (this is a cosmetic grouping, not isolation):
docker compose downin any stack targets the wholetestlabproject — to stop a single service usedocker compose stop <service>/docker compose rm <service>.--remove-orphansis intentionally omitted from the CIupcommands; with a shared project it would delete the other stacks' containers. A plainuplogs harmless "orphan containers" warnings.
Applying a service rename (one-time, manual). Because all stacks share the
project, you must remove the old-named containers by name and bring the renamed
ones up — never docker compose down (it would hit the whole group):
# example: after renaming proxy services in docker-compose.yml
docker rm -f testlab-nginx-1 oauth2-proxy ... # remove old-named containers
cd testlab-proxy && docker compose up -d # creates the new-named ones
A normal CI redeploy alone cannot switch a running stack to new names (the old containers still hold the ports) — do this manual step once, after which CI works normally.
6. iOS (host-native)
iOS runs directly on macOS. Run a second orchestrator instance on the host
(./gradlew bootRun, IOS_ENABLED=true is the default) and route platform=ios
jobs to it. Requirements:
- Xcode + signing under the lab team (
DEVELOPMENT_TEAM=VTBHR6ZFS7). The team has a wildcard "iOS Team Provisioning Profile: ", so any bundle id signs — do not* forcePRODUCT_BUNDLE_IDENTIFIER(it makes the app and UI-test runner collide and breaks signing). xctesthtmlreport(HTML report) and the iOS device tools (idevicescreenshot, etc.) installed on the host.- Example iOS apps need a shared scheme that tests only the UI-test target (the unit-test/logic target can't run on a device) and a deployment target low enough for the test devices (e.g. 16.2).
Listing iOS devices from the Docker container
Problem: the container is Linux and can't run xctrace (a macOS/Xcode tool),
so GET /api/v1/devices/ios would return an empty list straight from the
container — there's no Xcode there.
Solution (same idea as adb: the host produces the data, the container reads it):
- The launchd bridge script on the host (
scripts/adb-bridge.sh) runsxcrun xctrace list devicesevery ~20s and writes the output to/Users/Shared/testlab/xctrace-devices.txt. The write is atomic (write a.tmpfile, thenmv) so the container never reads a half-written file. - That directory is mounted into the container read-only as
/app/shared. - The container sets
IOS_DEVICES_FILE=/app/shared/xctrace-devices.txt.IOSDeviceBridgereads that file instead of runningxctrace(when the variable is set) and parses names/uuids the same way. - The host-native iOS instance does not set
IOS_DEVICES_FILE, so it queriesxctracedirectly.
Mac: xctrace ──(every 20s)──► /Users/Shared/testlab/xctrace-devices.txt
│ (volume, read-only)
Container: /app/shared/xctrace-devices.txt
│
GET /api/v1/devices/ios ──► ["TalTech's iPhone 8", ...]
Result: GET /api/v1/devices/ios returns iOS device data (including names)
from the container. Only online (USB-connected) devices are shown; offline
ones are excluded.
Note
This only provides the device list. iOS tests still cannot run in the
container (that needs Xcode on the Mac). The list is also cached
(@Cacheable), so changes (a newly connected device) appear after the cache
refreshes / the app restarts.
7. After a reboot — what should happen automatically
- Mac auto-logs-in to
testlab. - The launchd agent starts the adb server + socat bridge (
:5038). - Docker Desktop starts (login setting) and brings the orchestrator container
back up (
restart: unless-stopped). - Devices reconnect over USB; the adb server picks them up automatically.
No manual steps. If something is off, see Troubleshooting.
8. Troubleshooting
| Symptom | Cause / Fix |
|---|---|
Container adb devices empty |
Bridge down: launchctl list \| grep adb-bridge, check /tmp/adb-bridge.log; ensure socat listens on :5038. |
Operation not permitted running the bridge script |
Script is under ~/Desktop (macOS TCC). Install it to /Users/Shared/testlab/. |
qemu-x86_64: Could not open ld-linux... |
Image is arm64; Android tools are x86_64. Ensure platform: linux/amd64 + Rosetta. |
Failed to delete: output ... Device or resource busy |
output/ is a mounted volume; the app must clear its contents, not the dir (already fixed). |
/app/secrets: Read-only file system (Drive) |
Mount secrets/ read-write (Google writes its token cache there). |
iOS: Logic Testing Unavailable |
Device locked/unavailable or Developer Mode off; also use a shared scheme testing only the UI-test target. |
iOS: missing provisioning profile (...mobileprovision not found) |
Don't override PRODUCT_BUNDLE_IDENTIFIER; let the wildcard team profile sign (set only DEVELOPMENT_TEAM). |
iOS: xchtmlreport: command not found |
brew install xctesthtmlreport. |
Device unauthorized |
Accept "Trust this computer" on the device. |
| Android SDK not found in CI | The GitLab runner is a macOS shell executor; write local.properties with sdk.dir (see service docs). |