Exposing an Android App’s Local Web Server to the Internet

A standalone-APK tutorial — no VPS, no server, no router port forwarding. Embedded tunneling with ngrok and Cloudflare.

Contents
  1. How it works (why no port forwarding is needed)
  2. Option A — ngrok (official Java library, easiest)
  3. Option B — Cloudflare TryCloudflare (no account, fully anonymous)
  4. Battery optimization (both options)
  5. Comparison & recommendation
  6. Security notes

1. How it works

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.

2. Option A: ngrok (official Java library — easiest)

Step 1 — Get a free authtoken

  1. Sign up at dashboard.ngrok.com (free)
  2. Copy your authtoken from the Your Authtoken page

Step 2 — Gradle setup

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")
}

Step 3 — Permissions

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" />

Step 4 — The tunnel Foreground Service

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" />

Step 5 — Start/stop from your Activity

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.

Don’t hardcode the authtoken in production. Let users paste their own token into a settings screen, store it with EncryptedSharedPreferences, and read it in the service. This also avoids everyone sharing your free-tier session limit (1 concurrent session).

3. Option B: Cloudflare TryCloudflare (no account, no token, fully anonymous)

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.

Step 1 — Get the ARM binaries

Download from GitHub releases (cloudflare/cloudflared):

Step 2 — Package as “native libraries”

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"
    ... >

Step 3 — The tunnel service (subprocess version)

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.

Step 4 — Optional: stable URL with a named Cloudflare tunnel

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.

4. Battery optimization (both options)

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.

5. Comparison & recommendation

ngrok (Option A)cloudflared (Option B)
Account neededYes (free token)No (TryCloudflare)
APK size added~5–10 MB~35–50 MB (2 ABIs)
Integration styleClean Java APISubprocess + log parsing
Free stable URLNo (paid)Yes, with named tunnel + own domain
HTTPSAutomaticAutomatic
Interstitial pageYes on free tierNo

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.

6. Security notes