Most Android CI setups end up on GitHub Actions or Bitrise or some other cloud runner. They work fine, but once your free minutes run out or your team grows, the bill follows. I wanted to see if I could get the same result — automated builds, UI tests, security scans — on hardware I already had sitting on my desk.

The short version: it works. Here’s how it’s set up.

The stack

  • QNAP NAS — the machine everything runs on
  • Container Station — QNAP’s Docker management UI, used to run Jenkins and the other containers
  • Jenkins — the CI orchestrator that watches for PRs and runs the pipeline
  • Gradle — builds the APK inside the Jenkins pipeline
  • Espresso — Android UI tests that run as part of the build
  • MobSF — a static security scanner that inspects the APK before it’s allowed through
  • Cloudflare Tunnel — how GitHub reaches Jenkins without any port forwarding

The problem with running CI at home

The obvious issue with self-hosted CI is that your machine is behind a router at home. GitHub needs to send webhook events to Jenkins when a PR is opened — but your NAS doesn’t have a public IP address, and even if it did, you’d have to open ports and deal with a dynamic IP changing on you.

Cloudflare Tunnel solves this cleanly. You run a small agent (cloudflared) on the QNAP as another Docker container. It opens an outbound connection to Cloudflare’s edge and keeps it alive. Cloudflare gives you a permanent public URL — something like https://your-tunnel.cfargotunnel.com — and any traffic hitting that URL gets forwarded through the tunnel to Jenkins running locally. No ports opened. No domain purchase. No IP management.

From GitHub’s perspective, Jenkins just looks like any other server on the internet.

The pipeline

When a pull request targets main, GitHub fires a webhook to Jenkins. The pipeline runs three stages in order:

1. Build

Jenkins checks out the branch and runs the Gradle build inside the container:

./gradlew assembleDebug

If the build fails — compile error, missing dependency, whatever — the pipeline stops here and the PR is blocked.

2. UI Tests

Espresso tests run against the debug APK. This is the stage that catches things a unit test wouldn’t — navigation flows, UI state after an action, things that only break when the full app is running.

./gradlew connectedDebugAndroidTest

3. Security Scan

The APK gets passed to MobSF, which does a static analysis — checking for hardcoded secrets, insecure API usage, exported components that shouldn’t be, and a few dozen other things. If MobSF finds anything above the severity threshold, the stage fails.

All three stages have to pass for the PR to be mergeable. GitHub’s branch protection rules enforce this — the Jenkins job reports its status back to the PR, and the merge button stays locked until the check goes green.

Why this setup

The main reason is cost. Once it’s running, this pipeline costs nothing to operate. The NAS was already there. Cloudflare Tunnel is free. Jenkins is open source. The only thing you’re spending is electricity and the time it takes to set it up.

The second reason is that it’s genuinely interesting to build. Running a build system on a NAS, tunneling it through Cloudflare’s edge, wiring up GitHub webhooks — there are a few moving parts that have to fit together, and getting there teaches you something about how CI infrastructure actually works, rather than just filling in a YAML file and hoping for the best.

What’s left

The pipeline is functional but there are a few things I still want to add — artifact storage so built APKs are retained, Slack notifications when a build fails, and eventually wiring up the release signing so a passing build on main can go straight to a draft Play Store release.

More on those as they come together.