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
Thanks, useful write-up.
I ran into the same Android restrictions around executing cloudflared and Go DNS behavior.
In my case I ended up taking a slightly different route)
package cloudflared via jniLibs and run it from nativeLibraryDir
do Quick Tunnel provisioning from the app side first
if direct Quick API access fails on the network path, fall back to a small Cloudflare Worker proxy
keep cloudflared for the actual tunnel process, not for the initial Quick Tunnel lease
I also rebuilt the arm64 binary with 16 KB ELF alignment because Android now warns on unaligned native libs for newer devices.