You have an Android app running a local server. You want a public HTTPS URL so the world can reach it.
π± Your Android App (localhost:8088)
β
βοΈ Cloudflare Tunnel
β
π https://cool-name.trycloudflare.com β anyone can access this
π€ Can Android run cloudflared? β Yes. Android IS Linux.
π€ Does it need root? β No. But you'll fight 5 different Android restrictions.
π€ Is it free? β Yes. Quick Tunnels need no account, no credit card, nothing.
π€ Why is this guide so long? β Because every existing "just run cloudflared" tutorial silently fails on Android. We document WHY and HOW to fix each failure.
Built for ZeroClaw Android. Works for any Android app.
| What | Details |
|---|---|
| β Problem | Cloudflare doesn't publish Android builds |
| β Solution | Android IS Linux β use the linux-arm64 build |
| π₯ Download | cloudflared-linux-arm64 from GitHub releases |
curl -L -o libcloudflared.so \
https://github.com/cloudflare/cloudflared/releases/latest/download/cloudflared-linux-arm64
β οΈ We name itlibcloudflared.soβ this matters for Problem 2.
| What | Details |
|---|---|
| β Problem | Android 10+ blocks executing files from app directories |
| π Error | error=13, Permission denied |
| π€― Also fails | Dynamic linker: unexpected e_type: 2 |
| β Solution | Bundle as native library in APK |
π What DOESN'T Work
// β FAILS β filesDir is noexec
val binary = File(context.filesDir, "cloudflared")
binary.setExecutable(true)
ProcessBuilder(binary.absolutePath).start() // Permission denied
// β FAILS β linker expects shared libs, not executables
ProcessBuilder("/system/bin/linker64", binary.absolutePath).start()
// "unexpected e_type: 2"Step 1 β Place binary in jniLibs:
app/src/main/jniLibs/arm64-v8a/libcloudflared.so
Step 2 β Force extraction (build.gradle.kts):
android {
packaging {
jniLibs { useLegacyPackaging = true }
}
}Step 3 β Force extraction (AndroidManifest.xml):
<application android:extractNativeLibs="true" ...>Step 4 β Execute from nativeLibraryDir:
val binary = File(context.applicationInfo.nativeLibraryDir, "libcloudflared.so")
ProcessBuilder(binary.absolutePath, "tunnel", "--url", "http://localhost:8088").start()
// β
nativeLibraryDir HAS execute permission!π‘ Why both build.gradle AND manifest? Modern Android keeps
.sofiles compressed inside the APK. You need BOTH settings to extract them as real files on disk.
| What | Details |
|---|---|
| β Problem | Go reads /etc/resolv.conf for DNS β doesn't exist on Android |
| π Error | lookup api.trycloudflare.com on [::1]:53: connection refused |
| π§± Root cause | Path is HARDCODED in Go source. No env var override exists. |
π Everything We Tried That FAILED
| Attempt | Why It Failed |
|---|---|
π Create /etc/resolv.conf |
/etc/ is read-only (symlink to /system/etc/) |
π§ RES_CONF env var |
Not a real Go thing β we made it up hoping it existed |
π§ GODEBUG=netdns=cgo |
cloudflared is statically linked, no cgo available |
| π DNS relay on port 53 | EACCES β non-root can't bind ports below 1024 |
π HTTPS_PROXY env var |
cloudflared uses custom http.Transport{} with Proxy: nil β ignores proxy |
| π HTTP CONNECT proxy | Same β cloudflared bypasses proxy entirely |
π¦ Build with GOOS=android |
Would fix DNS, but requires compiling cloudflared from source |
7 approaches. All failed. Then we found the actual solution:
Java's InetAddress.getByName() uses Android's native DNS resolver. It works perfectly. The trick: do ALL DNS work from Java and pass results to cloudflared.
Phase 1 β Register tunnel from Java (not cloudflared):
// β
Java DNS works on Android!
val client = OkHttpClient()
val request = Request.Builder()
.url("https://api.trycloudflare.com/tunnel")
.post("".toRequestBody("application/json".toMediaType()))
.build()
val response = client.newCall(request).execute()
val result = JSONObject(response.body!!.string()).getJSONObject("result")
val tunnelId = result.getString("id") // UUID
val hostname = result.getString("hostname") // xxx.trycloudflare.com
val accountTag = result.getString("account_tag")
val secret = result.getString("secret") // base64Phase 2 β Write credentials file:
File(context.cacheDir, "tunnel_creds.json").writeText(
JSONObject().apply {
put("AccountTag", accountTag)
put("TunnelID", tunnelId)
put("TunnelSecret", secret)
}.toString()
)Phase 3 β Write config file:
File(context.cacheDir, "tunnel_config.yml").writeText("""
tunnel: $tunnelId
credentials-file: ${credsFile.absolutePath}
protocol: http2
ingress:
- hostname: $hostname
service: http://localhost:8088
- service: http_status:404
""".trimIndent())Phase 4 β Resolve edge IPs from Java:
val edgeIps = mutableListOf<String>()
for (host in listOf("region1.v2.argotunnel.com", "region2.v2.argotunnel.com")) {
InetAddress.getAllByName(host)
.filter { it is Inet4Address }
.forEach { edgeIps.add("${it.hostAddress}:7844") }
}Phase 5 β Run cloudflared with zero DNS needed:
val cmd = mutableListOf(binary.absolutePath, "tunnel",
"--config", configFile.absolutePath,
"--edge-ip-version", "4",
"--no-autoupdate")
for (ip in edgeIps.take(4)) cmd.addAll(listOf("--edge", ip))
cmd.addAll(listOf("run", tunnelId))
ProcessBuilder(cmd).directory(context.cacheDir).redirectErrorStream(true).start()π The tunnel URL is known INSTANTLY from Phase 1 β no stdout parsing needed!
| What | Details |
|---|---|
| β Problem | After registration, cloudflared needs SRV records for edge servers |
| π Error | lookup _v2-origintunneld._tcp.argotunnel.com on [::1]:53: refused |
| β Solution | Resolve edge hostnames from Java, pass via --edge flag |
Already handled in Phase 4 above. The key insight: cloudflared needs DNS for TWO things β the API call AND edge discovery. You must handle both from Java.
| What | Details |
|---|---|
| β Problem | Passing comma-separated IPs to --edge |
| π Error | too many colons in address |
| β Solution | One --edge per address |
# β WRONG
--edge 198.41.192.77:7844,198.41.192.107:7844
# β
RIGHT
--edge 198.41.192.77:7844 --edge 198.41.192.107:7844βββββββββββββββββββββββββββββββββββββββββββββββββββ
β π± ANDROID APP β
β β
β ββββββββββββββββββββββββββββββββββββββββββββ β
β β β Java/Kotlin (DNS works here!) β β
β β β β
β β 1οΈβ£ POST api.trycloudflare.com/tunnel β β
β β β tunnel ID, hostname, credentials β β
β β β β
β β 2οΈβ£ InetAddress.getAllByName( β β
β β "region1.v2.argotunnel.com") β β
β β β edge server IPs β β
β β β β
β β 3οΈβ£ Write creds.json + config.yml β β
β ββββββββββββββββββ¬ββββββββββββββββββββββββββ β
β β β
β ββββββββββββββββββββββββββββββββββββββββββββ β
β β π΅ cloudflared (libcloudflared.so) β β
β β from: nativeLibraryDir (exec β
) β β
β β β β
β β --config config.yml β β
β β --edge 198.41.192.77:7844 β β
β β --edge-ip-version 4 β β
β β --no-autoupdate β β
β β run <tunnel-id> β β
β β β β
β β β Direct IP connection. ZERO DNS. β
β β
β ββββββββββββββββββ¬ββββββββββββββββββββββββββ β
β β β
β ββββββββββββββββββββββββββββββββββββββββββββ β
β β π₯οΈ Your Local Server (:8088) β β
β ββββββββββββββββββββββββββββββββββββββββββββ β
βββββββββββββββββββββββββββββββββββββββββββββββββββ
β
π https://xxx.trycloudflare.com
accessible worldwide!
| # | Step | Done? |
|---|---|---|
| 1 | libcloudflared.so in jniLibs/arm64-v8a/ |
β |
| 2 | android:extractNativeLibs="true" in manifest |
β |
| 3 | useLegacyPackaging = true in build.gradle |
β |
| 4 | API call to trycloudflare.com done from Java |
β |
| 5 | Edge IPs resolved from Java | β |
| 6 | Credentials + config written to cacheDir |
β |
| 7 | Separate --edge IP:port flags (not comma-separated) |
β |
| 8 | --edge-ip-version 4 flag |
β |
| 9 | --no-autoupdate flag |
β |
| 10 | INTERNET permission in manifest |
β |
After tunnel connects, you'll see these. They're harmless:
| Error | Why It's Fine |
|---|---|
Failed to fetch features ... cfd-features.argotunnel.com |
Optional feature flags β tunnel works without them |
GID not within ping_group_range |
Android restricts ping β tunnel doesn't need it |
open /proc/sys/net/ipv4/ping_group_range: permission denied |
Same β irrelevant to tunnel operation |
| Question | Answer |
|---|---|
| π URL changes on restart? | Quick Tunnels: yes. Named Tunnels (with token): no |
| π¦ APK size impact? | +37MB. Use Git LFS for the repo |
| π₯οΈ x86 emulator? | No β ARM64 binary only. Need real device or ARM64 emulator |
π¨ Why not GOOS=android? |
Would fix DNS natively, but requires compiling cloudflared from Go source |
| π ngrok instead? | Same jniLibs approach works. ngrok may handle DNS better |
| π€ Need Cloudflare account? | Quick Tunnels: no. Named Tunnels: yes |
All documented in BUGS.md