Android Emulators in Docker Gitea Actions on x86 NixOS

Android emulation in CI requires hardware acceleration. Without KVM, emulators crawl. With proper setup, they run at native speed. Here’s how to configure Gitea Actions runners on NixOS for KVM-accelerated Android testing.

NixOS Host Configuration

The foundation requires KVM kernel modules and proper device permissions. NixOS loads KVM modules automatically on x86_64 systems, but container access needs explicit configuration.

# gitea_actions_runner.nix
{
  # Enable KVM for Android emulator hardware acceleration
  services.udev.extraRules = ''
    KERNEL=="kvm", GROUP="kvm", MODE="0666", OPTIONS+="static_node=kvm"
  '';

  services.gitea-actions-runner.instances = let
    labels = [
      "ubuntu-latest:docker://gitea/runner-images:ubuntu-latest"
      "ubuntu-22.04:docker://gitea/runner-images:ubuntu-22.04"
    ];
  in {
    "runner0" = {
      enable = true;
      name = "TeraCI amd64 (0)";
      url = "http://git.local.teralink.net:3000/";
      tokenFile = config.age.secrets.gitea_actions_runner_env.path;
      settings = {
        capacity = 2;
        container = {
          options = "--device /dev/kvm --group-add 302";
        };
      };
      inherit labels;
    };
  };
}

The critical line: options = "--device /dev/kvm --group-add 302";. This passes the KVM device into Docker containers and adds the container to the KVM group.

Docker Configuration

Enable Docker with systemd cgroup driver for proper container management:

# configuration.nix
virtualisation.docker = {
  enable = true;
  daemon.settings = {
    exec-opts = ["native.cgroupdriver=systemd"];
  };
};

boot.kernelModules = [
  "bridge"
  "veth"
  "overlay"
  # KVM modules load automatically on x86_64
];

Android Workflow Configuration

The Gitea Actions workflow needs specific Android SDK setup and managed device configuration:

name: Integration Tests

on:
  push:
    branches: [master, develop]
  pull_request:
    branches: [master, develop]

jobs:
  integration-tests:
    runs-on: ubuntu-latest
    timeout-minutes: 45

    env:
      GRADLE_OPTS: "-Xmx4g -Dorg.gradle.daemon=false"
      ANDROID_HOME: "/root/.android/sdk"
      ANDROID_SDK_ROOT: "/root/.android/sdk"

    steps:
      - name: Checkout code
        uses: actions/checkout@v4

      - name: Set up JDK 17
        uses: actions/setup-java@v4
        with:
          java-version: "17"
          distribution: "temurin"

      - name: Setup Android SDK
        uses: android-actions/setup-android@v3

      - name: Cache system images
        id: system-images-cache
        uses: actions/cache@v4
        with:
          path: |
            /root/.android/sdk/system-images
          key: android-system-images-atd-30-34-${{ runner.os }}

      - name: Install system images for managed devices
        if: steps.system-images-cache.outputs.cache-hit != 'true'
        run: |
          yes | sdkmanager --licenses || true
          sdkmanager "system-images;android-30;google_atd;x86_64"
          sdkmanager "system-images;android-34;google_atd;x86_64"

      - name: Run integration tests with managed devices
        run: |
          ./gradlew cleanManagedDevices --continue
          ./gradlew pixel2Api30DebugAndroidTest pixel6Api34DebugAndroidTest \
            -Pandroid.testInstrumentationRunnerArguments.notAnnotation=com.two13tec.zeitmeister.categories.ExcludeFromCI \
            --continue

Key points:

  • Uses google_atd (Android Test Device) images for faster CI execution
  • Caches system images to avoid repeated downloads
  • Runs tests on multiple API levels with managed devices
  • Excludes CI-incompatible tests with annotations

Gradle Managed Devices

Configure managed devices in your build.gradle:

android {
    testOptions {
        managedDevices {
            devices {
                pixel2Api30(ManagedVirtualDevice) {
                    device = "Pixel 2"
                    apiLevel = 30
                    systemImageSource = "google_atd"
                }
                pixel6Api34(ManagedVirtualDevice) {
                    device = "Pixel 6"
                    apiLevel = 34
                    systemImageSource = "google_atd"
                }
            }
        }
    }
}

Verification

Check KVM availability in the container:

# In the CI environment
ls -la /dev/kvm
# Should show: crw-rw-rw- 1 root kvm 10, 232 ... /dev/kvm

# Test emulator acceleration
emulator -accel-check
# Should report: accel: OK

Performance Notes

  • ATD images start ~40% faster than full Google Play images
  • System image caching is essential for build time optimization
  • KVM acceleration reduces emulator boot time from 5+ minutes to ~30 seconds
  • Gradle managed devices handle emulator lifecycle automatically

The setup works because NixOS properly configures KVM permissions, Docker passes the device through to containers, and Gradle managed devices leverage hardware acceleration for efficient Android testing in CI.

Without KVM passthrough, Android emulators timeout or fail entirely. With it, integration tests complete in reasonable time. The configuration is straightforward once the device permissions align correctly.