A standalone-APK tutorial — no VPS, no server, no router port forwarding. Embedded tunneling with ngrok and Cloudflare.
Your APK hosts a web server locally (e.g. on port 8080). To make it reachable from the internet without any external infrastructure, the app embeds a tunneling client. The client dials outbound to a tunnel service (ngrok or Cloudflare), which assigns a public HTTPS URL and relays visitor traffic back through that outbound connection to your local server.
Because the connection is outbound only, it works behind any NAT, carrier-grade NAT, or mobile network — no port forwarding, no router access, no VPS.
dashboard.ngrok.com (free)app/build.gradle.kts:
android {
// ...
packaging {
jniLibs {
useLegacyPackaging = true
}
}
}
dependencies {
implementation("com.ngrok:ngrok-java:1.1.0")
implementation("com.ngrok:ngrok-java-native:1.1.0")
}
AndroidManifest.xml:
<uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_DATA_SYNC" />
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
<uses-permission android:name="android.permission.WAKE_LOCK" />
This is the critical part on Android — the tunnel must live in a Foreground Service or the OS will kill it when the screen locks.
TunnelService.kt:
package com.example.mywebapp
import android.app.*
import android.content.Intent
import android.os.Build
import android.os.IBinder
import android.os.PowerManager
import androidx.core.app.NotificationCompat
import com.ngrok.Session
import com.ngrok.Http
import kotlinx.coroutines.*
class TunnelService : Service() {
companion object {
const val CHANNEL_ID = "tunnel_channel"
const val NOTIF_ID = 1001
const val EXTRA_LOCAL_PORT = "local_port"
const val ACTION_URL_READY = "com.example.mywebapp.URL_READY"
const val EXTRA_PUBLIC_URL = "public_url"
@Volatile var publicUrl: String? = null
private set
}
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
private var session: Session? = null
private var wakeLock: PowerManager.WakeLock? = null
override fun onCreate() {
super.onCreate()
createNotificationChannel()
}
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
val localPort = intent?.getIntExtra(EXTRA_LOCAL_PORT, 8080) ?: 8080
startForeground(NOTIF_ID, buildNotification("Starting tunnel..."))
acquireWakeLock()
scope.launch {
try {
startTunnel(localPort)
} catch (e: Exception) {
updateNotification("Tunnel failed: ${e.message}")
}
}
return START_STICKY
}
private suspend fun startTunnel(localPort: Int) {
// Build the ngrok session (dials out -- no port forwarding needed)
val builder = Session.newBuilder("YOUR_NGROK_AUTHTOKEN")
.metadata("android-app")
session = builder.connect()
// Open an HTTP endpoint forwarding to your local server
val listener = session!!.httpEndpoint().listen()
publicUrl = listener.url
updateNotification("Live at ${listener.url}")
// Broadcast the URL so the UI can show it
sendBroadcast(Intent(ACTION_URL_READY).apply {
setPackage(packageName)
putExtra(EXTRA_PUBLIC_URL, listener.url)
})
// Forward all tunnel traffic to the local web server
listener.forwardTcp("localhost:$localPort")
}
private fun acquireWakeLock() {
val pm = getSystemService(POWER_SERVICE) as PowerManager
wakeLock = pm.newWakeLock(
PowerManager.PARTIAL_WAKE_LOCK, "mywebapp:tunnel"
).apply { acquire() }
}
private fun createNotificationChannel() {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
val channel = NotificationChannel(
CHANNEL_ID, "Tunnel Status",
NotificationManager.IMPORTANCE_LOW
)
getSystemService(NotificationManager::class.java)
.createNotificationChannel(channel)
}
}
private fun buildNotification(text: String): Notification =
NotificationCompat.Builder(this, CHANNEL_ID)
.setContentTitle("Web server tunnel")
.setContentText(text)
.setSmallIcon(android.R.drawable.stat_sys_upload)
.setOngoing(true)
.build()
private fun updateNotification(text: String) {
val nm = getSystemService(NotificationManager::class.java)
nm.notify(NOTIF_ID, buildNotification(text))
}
override fun onDestroy() {
scope.cancel()
runCatching { session?.close() }
wakeLock?.let { if (it.isHeld) it.release() }
publicUrl = null
super.onDestroy()
}
override fun onBind(intent: Intent?): IBinder? = null
}
Register it in AndroidManifest.xml:
<service
android:name=".TunnelService"
android:foregroundServiceType="dataSync"
android:exported="false" />
class MainActivity : AppCompatActivity() {
private val urlReceiver = object : BroadcastReceiver() {
override fun onReceive(context: Context, intent: Intent) {
val url = intent.getStringExtra(TunnelService.EXTRA_PUBLIC_URL)
findViewById<TextView>(R.id.urlText).text = url
// Copy to clipboard for convenience
val clipboard = getSystemService(ClipboardManager::class.java)
clipboard.setPrimaryClip(ClipData.newPlainText("url", url))
}
}
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContentView(R.layout.activity_main)
registerReceiver(
urlReceiver,
IntentFilter(TunnelService.ACTION_URL_READY),
RECEIVER_NOT_EXPORTED
)
findViewById<Button>(R.id.startBtn).setOnClickListener {
val intent = Intent(this, TunnelService::class.java).apply {
putExtra(TunnelService.EXTRA_LOCAL_PORT, 8080) // your web server port
}
ContextCompat.startForegroundService(this, intent)
}
findViewById<Button>(R.id.stopBtn).setOnClickListener {
stopService(Intent(this, TunnelService::class.java))
}
}
override fun onDestroy() {
unregisterReceiver(urlReceiver)
super.onDestroy()
}
}
That’s it — press Start, and within ~2 seconds you get a public https://xxxx.ngrok-free.app URL forwarding to your app’s local server.
EncryptedSharedPreferences, and read it in the service. This also avoids everyone sharing your free-tier session limit (1 concurrent session).
This bundles the cloudflared binary in your APK and runs it as a subprocess. No sign-up needed at all — you get a random https://xxxx.trycloudflare.com URL.
Download from GitHub releases (cloudflare/cloudflared):
cloudflared-linux-arm64 (for arm64-v8a)cloudflared-linux-arm (for armeabi-v7a)This is the trick: Android 10+ blocks executing files from your app data directory, but files in jniLibs are installed executable. Rename the binaries with a lib...so name:
app/src/main/jniLibs/
├── arm64-v8a/
│ └── libcloudflared.so <- renamed cloudflared-linux-arm64
└── armeabi-v7a/
└── libcloudflared.so <- renamed cloudflared-linux-arm
app/build.gradle.kts:
android {
// ...
packaging {
jniLibs {
useLegacyPackaging = true // extracts .so files to disk so they can be executed
}
}
}
And in AndroidManifest.xml inside <application>:
<application
android:extractNativeLibs="true"
... >
CloudflaredService.kt:
package com.example.mywebapp
import android.app.*
import android.content.Intent
import android.os.Build
import android.os.IBinder
import android.os.PowerManager
import androidx.core.app.NotificationCompat
import kotlinx.coroutines.*
import java.io.BufferedReader
import java.io.File
import java.io.InputStreamReader
class CloudflaredService : Service() {
companion object {
const val CHANNEL_ID = "cf_tunnel_channel"
const val NOTIF_ID = 1002
const val EXTRA_LOCAL_PORT = "local_port"
const val ACTION_URL_READY = "com.example.mywebapp.CF_URL_READY"
const val EXTRA_PUBLIC_URL = "public_url"
// trycloudflare prints the URL to stderr in a log line
private val URL_REGEX = Regex("https://[a-z0-9-]+\\.trycloudflare\\.com")
}
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
private var process: Process? = null
private var wakeLock: PowerManager.WakeLock? = null
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
val localPort = intent?.getIntExtra(EXTRA_LOCAL_PORT, 8080) ?: 8080
createNotificationChannel()
startForeground(NOTIF_ID, buildNotification("Starting Cloudflare tunnel..."))
acquireWakeLock()
scope.launch { runCloudflared(localPort) }
return START_STICKY
}
private fun runCloudflared(localPort: Int) {
// The binary lives in the native lib dir, installed executable
val binary = File(applicationInfo.nativeLibraryDir, "libcloudflared.so")
if (!binary.exists()) {
updateNotification("cloudflared binary missing for this ABI")
return
}
try {
process = ProcessBuilder(
binary.absolutePath,
"tunnel",
"--url", "http://localhost:$localPort",
"--no-autoupdate"
)
.redirectErrorStream(true)
.directory(filesDir)
.apply { environment()["HOME"] = filesDir.absolutePath }
.start()
// Parse output to find the public URL
val reader = BufferedReader(InputStreamReader(process!!.inputStream))
var line: String?
while (reader.readLine().also { line = it } != null) {
val match = URL_REGEX.find(line ?: "")
if (match != null) {
val url = match.value
updateNotification("Live at $url")
sendBroadcast(Intent(ACTION_URL_READY).apply {
setPackage(packageName)
putExtra(EXTRA_PUBLIC_URL, url)
})
}
}
} catch (e: Exception) {
updateNotification("Tunnel error: ${e.message}")
}
}
private fun acquireWakeLock() {
val pm = getSystemService(POWER_SERVICE) as PowerManager
wakeLock = pm.newWakeLock(
PowerManager.PARTIAL_WAKE_LOCK, "mywebapp:cftunnel"
).apply { acquire() }
}
private fun createNotificationChannel() {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
getSystemService(NotificationManager::class.java).createNotificationChannel(
NotificationChannel(CHANNEL_ID, "Cloudflare Tunnel", NotificationManager.IMPORTANCE_LOW)
)
}
}
private fun buildNotification(text: String): Notification =
NotificationCompat.Builder(this, CHANNEL_ID)
.setContentTitle("Cloudflare tunnel")
.setContentText(text)
.setSmallIcon(android.R.drawable.stat_sys_upload)
.setOngoing(true)
.build()
private fun updateNotification(text: String) {
getSystemService(NotificationManager::class.java)
.notify(NOTIF_ID, buildNotification(text))
}
override fun onDestroy() {
scope.cancel()
process?.destroy()
wakeLock?.let { if (it.isHeld) it.release() }
super.onDestroy()
}
override fun onBind(intent: Intent?): IBinder? = null
}
Register in the manifest the same way as Option A. Starting/stopping from the Activity is identical — just swap the service class and broadcast action.
TryCloudflare URLs are random each session. For a permanent URL, create a named tunnel once on the Cloudflare dashboard (Zero Trust → Tunnels), copy the tunnel token, and change the command to:
ProcessBuilder(
binary.absolutePath,
"tunnel", "run",
"--token", userProvidedToken, // from a settings screen
"--no-autoupdate"
)
This requires a free Cloudflare account and a domain, but the URL never changes.
Android’s Doze mode can still throttle the network. Ask the user for exemption:
fun requestBatteryExemption(activity: Activity) {
val pm = activity.getSystemService(PowerManager::class.java)
if (!pm.isIgnoringBatteryOptimizations(activity.packageName)) {
activity.startActivity(
Intent(Settings.ACTION_REQUEST_IGNORE_BATTERY_OPTIMIZATIONS).apply {
data = Uri.parse("package:${activity.packageName}")
}
)
}
}
Add <uses-permission android:name="android.permission.REQUEST_IGNORE_BATTERY_OPTIMIZATIONS" /> to the manifest.
| ngrok (Option A) | cloudflared (Option B) | |
|---|---|---|
| Account needed | Yes (free token) | No (TryCloudflare) |
| APK size added | ~5–10 MB | ~35–50 MB (2 ABIs) |
| Integration style | Clean Java API | Subprocess + log parsing |
| Free stable URL | No (paid) | Yes, with named tunnel + own domain |
| HTTPS | Automatic | Automatic |
| Interstitial page | Yes on free tier | No |
Advice: start with Option B (TryCloudflare) if you want zero-signup UX for your users, or Option A if you want the cleanest code and don’t mind users providing an ngrok token. Caveat for both: free-tier URLs change on every restart, so if your app needs a persistent link, the named Cloudflare tunnel with a user-supplied token is the only free path.
localhost/0.0.0.0 on the port you pass to the tunnel.EncryptedSharedPreferences.