Speeding Up Android Builds on GitHub Actions

Run Android emulator jobs on WarpBuild x86-64 runners by appending nested-virtualization.enabled=true to a warp- label and adding a KVM permissions step.

Last verified:

Android emulator jobs on GitHub Actions need hardware acceleration through /dev/kvm, and on WarpBuild you enable it by appending nested-virtualization.enabled=true to an x86-64 warp- runner label and adding one KVM permissions step before the emulator starts. This page covers the exact workflow configuration, runner sizing for assemble, unit test, and instrumented test jobs, the four bottlenecks that dominate Android build time, and a worked cost model against GitHub-hosted runner list prices.

Overview

Android is one of the heaviest workloads you can put on GitHub Actions. A typical pipeline compiles Kotlin across many Gradle modules, runs JVM unit tests, then boots a full Android system image inside an emulator for instrumented tests. Each stage stresses a different resource: compilation wants cores and memory, unit tests want JVM warm-up amortized away, and the emulator wants KVM virtualization support on the host.

The emulator is the part teams get wrong most often. The Android emulator runs x86-64 system images at useful speed only when it can use hardware acceleration through /dev/kvm. When /dev/kvm is missing or unreadable, the emulator silently switches to software emulation, and instrumented test jobs that should take minutes take much longer or time out.

WarpBuild provides Linux x64, Linux ARM64, macOS, and Windows runners. For Android emulator work the requirement is specific: nested virtualization is available on Linux x86-64 cloud runners through a dynamic label, and it is not currently supported on ARM64 runners, so emulator jobs must run on x86-64 labels. The full support matrix is in the nested virtualization docs.

Assemble and unit test jobs have no KVM requirement, so they run on any Linux runner size. The cloud runner catalog lists every label, size, and per-minute rate, and the pricing page carries the full rate card.

This page pairs with the Gradle tuning guide for build-level configuration and the general GitHub Actions speed playbook for workflow-level patterns that apply beyond Android.

Configuration

WarpBuild runners are drop-in replacements for GitHub-hosted runners, so migration is a label change. The one Android-specific addition is the nested virtualization dynamic label, which is appended to the runs-on value with a semicolon:

runs-on: warp-ubuntu-latest-x64-16x;nested-virtualization.enabled=true

WarpBuild provisions that runner on hardware that supports nested virtualization and exposes the virtualization extensions to the guest, which makes /dev/kvm available. If you put the label on an unsupported runner type, it is silently ignored and the job runs without nested virtualization, so a job that seems fine but runs slowly is worth checking for this mistake first.

One more step is required after /dev/kvm exists. The default device permissions (crw-rw---- root:kvm) prevent the runner user from opening it, so the workflow needs a udev rule before the emulator starts. This step is required on all GitHub and GitHub-compatible runners; it is standard Android emulator setup rather than a WarpBuild-specific workaround.

Here is a complete three-job Android workflow with Gradle caching and AVD caching:

name: android
on:
  pull_request:
  push:
    branches: [main]

jobs:
  assemble:
    runs-on: warp-ubuntu-latest-x64-8x
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-java@v4
        with:
          distribution: temurin
          java-version: 17
      - uses: gradle/actions/setup-gradle@v4
      - run: ./gradlew assembleDebug --build-cache

  unit-tests:
    runs-on: warp-ubuntu-latest-x64-8x
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-java@v4
        with:
          distribution: temurin
          java-version: 17
      - uses: gradle/actions/setup-gradle@v4
      - run: ./gradlew testDebugUnitTest --build-cache

  instrumented-tests:
    runs-on: warp-ubuntu-latest-x64-16x;nested-virtualization.enabled=true
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-java@v4
        with:
          distribution: temurin
          java-version: 17
      - uses: gradle/actions/setup-gradle@v4
      - name: Enable KVM group perms
        run: |
          echo 'KERNEL=="kvm", GROUP="kvm", MODE="0666", OPTIONS+="static_node=kvm"' \
            | sudo tee /etc/udev/rules.d/99-kvm4all.rules
          sudo udevadm control --reload-rules
          sudo udevadm trigger --name-match=kvm
      - name: Cache AVD
        uses: actions/cache@v4
        with:
          path: |
            ~/.android/avd/*
            ~/.android/adb*
          key: avd-api-34
      - name: Run instrumented tests
        uses: reactivecircus/android-emulator-runner@v2
        with:
          api-level: 34
          arch: x86_64
          disable-animations: true
          script: ./gradlew connectedDebugAndroidTest --build-cache

Three details matter in this file.

The Android SDK is preinstalled. WarpBuild Linux x86-64 runner images carry the same tooling as GitHub-hosted runner images, so the SDK, platform tools, and common build dependencies are already on disk. Your workflow avoids installing the SDK from scratch on every run.

Gradle caching comes from two layers. gradle/actions/setup-gradle persists the Gradle wrapper, dependency caches, and configuration cache data between runs, while --build-cache lets Gradle reuse task outputs. WarpBuild cache is enabled by default on Linux runners, so actions/cache and setup-gradle write to WarpBuild's cache backend without any workflow changes.

AVD caching skips device re-creation. Caching ~/.android/avd/* and ~/.android/adb* lets reactivecircus/android-emulator-runner restore an existing virtual device and boot from its snapshot instead of creating the device from a cold system image on every run.

On BYOC, the label is unnecessary. BYOC runs on AWS, GCP, and Azure, and nested virtualization is automatically enabled on a BYOC runner set when all of the selected instance types support it. If you mix supporting and non-supporting instance types, it stays off for consistency across jobs, so keep emulator runner sets on a uniform instance family.

Sizing

These are the Linux x86-64 sizes and rates, from the cloud runner catalog. Ubuntu 22.04 and 26.04 labels exist at the same rates.

Runner labelvCPUMemoryStoragePrice per minute
warp-ubuntu-latest-x64-2x28GB150GB SSD$0.004
warp-ubuntu-latest-x64-4x416GB150GB SSD$0.008
warp-ubuntu-latest-x64-8x832GB150GB SSD$0.016
warp-ubuntu-latest-x64-16x1664GB150GB SSD$0.032
warp-ubuntu-latest-x64-32x32128GB150GB SSD$0.064

For Android work, the useful range is 8x to 16x:

Job typeRecommended labelReasoning
Assemblewarp-ubuntu-latest-x64-8xKotlin and Java compilation parallelizes across modules; 8 vCPU and 32GB covers most multi-module apps. Move to 16x when the module graph is wide enough to keep 16 cores busy.
Unit testswarp-ubuntu-latest-x64-8xJVM tests scale with maxParallelForks; each fork wants a core and real memory. 16x pays off when the suite is large and well parallelized.
Instrumented testswarp-ubuntu-latest-x64-16xThe emulator claims several cores and gigabytes of memory for itself while Gradle, adb, and the test orchestrator run beside it. 16x also leaves room for two emulators in a sharded matrix.

The 2x and 4x sizes are too small for emulator work: once the emulator takes its share, Gradle is left starved. They remain reasonable for lint-only or small library jobs.

Worked cost model

GitHub publishes list prices for its hosted runners on the GitHub Actions minute multipliers reference, checked on 2026-08-13: Linux 8-core at $0.022 per minute and Linux 16-core at $0.042 per minute for private repositories. Public repositories get free minutes on GitHub's standard runners, so this model applies to private repositories.

Take a pipeline that runs 400 times per month: a 10 minute assemble job on 8 vCPU, an 8 minute unit test job on 8 vCPU, and a 25 minute instrumented test job on 16 vCPU. Holding job durations equal on both platforms:

JobMinutesWarpBuild rateWarpBuild costGitHub-hosted rateGitHub-hosted cost
Assemble (8 vCPU)10$0.016/min$0.16$0.022/min$0.22
Unit tests (8 vCPU)8$0.016/min$0.128$0.022/min$0.176
Instrumented tests (16 vCPU)25$0.032/min$0.80$0.042/min$1.05
Per pipeline run43$1.088$1.446
Per month at 400 runs17,200$435.20$578.40

The difference at these volumes is $143.20 per month with durations held equal on both platforms. Full rates are on the pricing page.

Bottlenecks

Four bottlenecks account for most slow Android pipelines on GitHub Actions.

1. Emulator boot time. Creating an AVD from a cold system image and booting it dominates short instrumented test suites. The fix is snapshot reuse: cache the AVD directory as shown above so the emulator boots from a saved snapshot, run with disable-animations: true, and keep the emulator headless. reactivecircus/android-emulator-runner supports a dedicated snapshot-generation run whose only job is to create the AVD and save the snapshot for later cache hits.

2. KVM availability. Without /dev/kvm, the emulator action's ProbeKVM check fails and it silently launches the emulator with -accel off, which means pure software emulation. The log symptoms are explicit: ProbeKVM: This user doesn't have permissions to use KVM (/dev/kvm)., then Disabling Linux hardware acceleration., and an emulator command line containing -accel off. The fix on WarpBuild is the two-part configuration above: the nested-virtualization.enabled=true dynamic label on an x86-64 runner, plus the KVM permissions step. After both are in place those messages disappear and tests run with hardware acceleration.

3. Gradle daemon cold start. WarpBuild runners are ephemeral VMs, so every job starts a fresh Gradle daemon and a cold JVM. You can win most of that time back with the configuration cache, the remote-friendly build cache via --build-cache, and gradle/actions/setup-gradle persisting dependency and wrapper caches between runs. Compilation avoids repetition even though the process starts cold.

4. SDK and AVD image downloads. System images, platform packages, and emulator binaries are large downloads when fetched inside the job. WarpBuild x86-64 images ship the same preinstalled tooling as GitHub-hosted images, which removes the SDK bootstrap, and the AVD cache removes repeated system image unpacking. Anything your build fetches beyond the preinstalled set belongs in a cache key.

When a pipeline is still slow after these four are handled, measure before guessing. WarpBuild's CI observability correlates OpenTelemetry system metrics from the runner agent with GitHub Actions job logs, which shows whether an instrumented test job is CPU-bound, memory-bound, or waiting on the network. For interactive debugging, the Action Debugger pauses a workflow and opens an SSH session on the runner, so you can inspect /dev/kvm permissions, emulator flags, and Gradle daemon state on the live machine.

FAQ

Can I run the Android emulator on WarpBuild ARM64 runners?

No. Nested virtualization is not currently supported on ARM64 runners, so emulator jobs must use x86-64 labels such as warp-ubuntu-latest-x64-8x. Plain assemble and unit test jobs run fine on ARM64 if your toolchain supports it.

Do I still need the KVM permissions step on WarpBuild runners?

Yes. Default /dev/kvm permissions block the runner user on all GitHub and GitHub-compatible runners, so add the udev rules step before starting the emulator. Without it the emulator falls back to software emulation.

What does an Android pipeline cost on WarpBuild runners?

warp-ubuntu-latest-x64-8x is $0.016 per minute and warp-ubuntu-latest-x64-16x is $0.032 per minute.

What happens if I put the nested virtualization label on an unsupported runner?

The label is silently ignored and the job runs without /dev/kvm. That includes ARM64, Windows, and macOS runners. Emulator jobs belong on Linux x86-64 labels.

Start with $10 in free credits

Change the runner label in your workflow and keep the rest of your GitHub Actions setup. Runner time is billed per minute.