Networking & port forwarding

The Alpine guest has full outbound internet access from first boot. Inbound access - SSH, VNC, container services - works through port forwarding, with a few rules injected automatically and the rest configured in Settings.

Guest network: SLIRP user-mode

On the QEMU backend the guest network is provided by SLIRP, a userspace TCP/IP stack embedded inside QEMU. No root, no kernel TUN/TAP device, and no host-side network interface are involved. From inside the guest the network looks like a small NAT:

AddressRole
10.0.2.15/24Guest IP (eth0)
10.0.2.2Default gateway (SLIRP host)
10.0.2.3SLIRP's DNS forwarder - dead on Android, which has no host /etc/resolv.conf for it to read
8.8.8.8, 1.1.1.1Fallback DNS (in /etc/resolv.conf)

IPv6 is disabled on the QEMU netdev (ipv6=off). Outbound TCP and UDP both work. The podroid-network OpenRC service brings up eth0 with these addresses at boot. Because SLIRP's own forwarder is dead, the guest's /etc/resolv.conf lists the device's own resolver first (passed to the guest via podroid.dns= on the kernel command line), then 8.8.8.8 and 1.1.1.1. In airplane mode there is no device resolver, so the list falls back to 10.0.2.3, 8.8.8.8, 1.1.1.1.

AVF backend

On the AVF backend the guest receives its IP from Android's built-in tethering via DHCP (udhcpc), rather than a static SLIRP address. The default route and DNS are assigned by the Android network stack. Inbound connectivity still works through port forwarding as described below, but the transport is vsock rather than SLIRP hostfwd.

Implicit port forwards

Three port forwards are injected automatically every time the VM starts. They never appear in the port-forward UI and cannot be removed from there:

ServiceHost portGuest portProtocolWhen active
SSH (dropbear) 9922 22 TCP When SSH is enabled in Settings
X11 / VNC (Xvnc) 5900 5900 TCP Always (backs the in-app screen viewer)
PulseAudio 4713 4713 TCP Always (backs the in-app audio stream)

The SSH forward is conditional: if you turn SSH off in Settings, the 9922→22 rule is not added and dropbear does not start inside the guest. The VNC and PulseAudio forwards are unconditional because the in-app X11 viewer depends on them.

Adding your own rules

Open Settings → Port forwards → Add. Enter a host port (on the Android device), a VM port (inside the guest), and pick TCP, UDP, or Both. On the QEMU backend rules apply live via the QMP protocol - no VM restart is needed. Rules are persisted across reboots. Next to Add there is also a Clean all button that removes every user rule at once, behind a confirmation dialog; the app's own SSH, display, and audio forwards are not affected.

A typical setup for a container service listening on guest port 8080:

# Inside the guest: start your service on port 8080
podman run -d -p 8080:80 nginx

# In Settings -> Port forwards -> Add:
#   Host port:  8080
#   VM port:    8080
#   Protocol:   TCP
#
# Then from your laptop on the same Wi-Fi:
curl http://<phone-ip>:8080

Limits on the rule list

A host port that something else on the phone is already using cannot be forwarded. That rule alone fails and is retried whenever the list changes; the VM keeps running and every other rule is unaffected. Android hands out ports from 32768 upwards to ordinary apps, so a rule in that range is the most likely one to collide - pick host ports below it when you have the choice.

Up to 2048 rules can be saved. The limit exists because each rule is a listening socket and a row in Settings, and because a shell loop can otherwise add thousands of them in a second. podroid-forward reports an error rather than reporting success once the list is full.

Talking back to Android from the guest

Several command-line tools ship inside the VM so scripts and shell sessions can reach the Android side directly, without the in-app UI. They all work on the QEMU and AVF backends.

podroid-forward - manage port forwards from the shell

# forward phone :8888 -> guest :80
podroid-forward add 8888 80 tcp
# shorthand for "add ... tcp"
podroid-forward 8888 80
# list current rules
podroid-forward list
# drop a rule
podroid-forward remove 8888 tcp
# drop every user rule at once (replies "removed N")
podroid-forward clean

Rules created this way are identical to ones added in Settings: they apply live, persist across reboots, and appear in the Settings list. Both TCP and UDP work on either backend. clean removes only user rules - the app's own SSH, display, and audio forwards are untouched.

podroid-notify - post an Android notification

# simplest form
podroid-notify "build finished"
# with a title and priority (low | normal | high)
podroid-notify --title Podroid --priority high "container crashed"
# reuse --id to update the same notification in place
podroid-notify --id 7 "progress 50%"

Handy for long builds, downloads, cron jobs and container health alerts. Tapping the notification opens Podroid. Requires the standard Android notification permission, requested at first run.

podroid-open - open a URL on the phone

# open a link in the phone's default browser
podroid-open https://github.com/ExTV/Podroid

Hands the URL to Android's ACTION_VIEW intent, so it opens in whatever app handles it on the phone. Only http and https URLs are accepted.

podroid-power - control the VM from inside the guest

# stop the VM cleanly - the app returns to its stopped state
podroid-power stop
# restart the VM
podroid-power restart
# print the current VM state, e.g. Running
podroid-power status

A clean lifecycle hook routed through the app, unlike an in-guest poweroff (which the app would treat as an unexpected exit). Useful for scripts that need to cycle the VM, or to shut it down from an SSH session.

podroid-server - server (headless) mode

# turn server mode on - the screen goes black at minimum brightness
podroid-server on
# turn it back off
podroid-server off
# query state (on | off)
podroid-server status

Server mode keeps the VM running behind a pure-black, minimum-brightness full-screen overlay - for leaving the phone on a headless workload with minimal OLED draw and burn-in. The screen stays on (so the system does not kill the VM) but shows nothing. Exit by holding the screen for three seconds, or run podroid-server off. The same toggle lives in the terminal's top bar. podroid-headless is an alias for podroid-server.

How it works

A small guest daemon (podroid-hostd) relays each request to the Android side over the same control transport the app already uses - a virtio-console port on QEMU, vsock on AVF - which then performs the requested action: post a notification, write a port-forward rule, open a URL, or control the VM. Any process that can reach the guest socket can use these, so they work from a container too if you bind-mount /run/podroid-host.sock into it.

LAN reachability

All port forward listeners bind 0.0.0.0, not 127.0.0.1. This means any device on the same Wi-Fi network can reach the phone by its local IP address. The phone's current IP is shown in Settings.

# SSH from a laptop on the same Wi-Fi
$ ssh root@<phone-ip> -p 9922
# password: podroid

# VNC from any viewer
$ vncviewer <phone-ip>:5900

# HTTP service forwarded to host port 8080
$ curl http://<phone-ip>:8080

The privileged-port limit

Android apps run as unprivileged Linux users and do not hold CAP_NET_BIND_SERVICE. The Linux kernel refuses to bind a TCP or UDP socket to any port below 1024 for a process without that capability. This is a kernel-level rule, not a Podroid restriction, and there is no in-app workaround without root.

Consequences:

Workaround

Forward to a high host port instead of the standard one: map host 8080 to guest 80, and host 8443 to guest 443. A daemon running as guest-root can still listen on 80 or 443. A rootless Podman or Docker container is the exception: its published port is also subject to the ≥1024 limit inside the guest, so publish to a high guest port too - e.g. a Pi-hole DNS container on guest 5353/udp, forwarded to host 5353, with LAN clients pointed at <phone-ip>:5353.

# Forward a web server on guest :80 to a high host port
# Settings -> Port forwards -> Add:  8080 -> 80  TCP

# Then reach it from outside:
$ curl http://<phone-ip>:8080

Sharing files over the network

Getting files in and out of the guest from another computer is a common first task, and which protocols can work is decided almost entirely by the privileged-port rule above. This section gives the one that works well, the one that works with a caveat, and the ones that cannot work at all, so you are not left testing them one by one.

SFTP, the one to reach for first

SSH is already running on host port 9922, so file transfer needs no second service, no extra rule, and no setup at all: the guest ships the SFTP helper alongside dropbear. Turn SSH on in Settings and connect. Any SFTP client reaches <phone-ip> on port 9922 with the guest's own credentials. WinSCP and FileZilla both speak it, as does the command line:

$ sftp -P 9922 root@<phone-ip>

It is encrypted, it needs one port instead of a range, and it goes through the rule the app already creates for you.

On an older VM image, one more line

The helper ships from v1.2.7 onwards. If sftp connects and then closes immediately, the image predates it: run apk add openssh-sftp-server in the guest once.

FTP, when the client cannot do anything else

Some old clients only speak FTP. It works, but passive mode needs care: the server tells the client which port to open for the data connection, so that port has to be forwarded too. Pin the passive range to a handful of ports rather than forwarding hundreds, and forward each one to the same number on both sides.

# inside the VM
# apk add vsftpd

# /etc/vsftpd/vsftpd.conf
listen=YES
listen_ipv6=NO
anonymous_enable=NO
local_enable=YES
write_enable=YES
chroot_local_user=YES
allow_writeable_chroot=YES
seccomp_sandbox=NO
pasv_enable=YES
pasv_min_port=42100
pasv_max_port=42109
pasv_address=<phone-ip>
pasv_addr_resolve=NO

# rc-service vsftpd start
# rc-update add vsftpd

Then add eleven forwards: host 2121 to VM 21 for the control connection (21 is privileged on the Android side, so it cannot be the host port), and 42100 through 42109 to the same numbers. Connect to ftp://<phone-ip>:2121.

Why it works in FileZilla but not in Windows Explorer

Modern clients use the extended passive command, which returns only a port and reuses the address they are already connected to, so they work whether or not pasv_address is set. Windows Explorer uses the older command, which returns an address as well, and the guest fills that in with its internal address 10.0.2.15. The client then tries to open the data connection to an address that does not exist on your network and hangs, usually after the directory listing appears to start. Setting pasv_address to the phone's own address is what fixes it. The phone's address changes when it moves between networks, so it has to be updated to match; the Status screen lists every address the phone currently has.

NFS and iSCSI

The kernel ships an NFSv4 server and client, so the guest can serve NFS - with one constraint. The export must live on an ext4-backed path such as /mnt/persist and be exported with fsid=0, because the overlay root cannot produce NFS filehandles by design. Note that Windows' own NFS client speaks version 3, which also needs the portmapper on port 111 - privileged, so Windows still cannot reach it. The kernel also ships the LIO iSCSI target (targetcli, with fileio and iblock backstores) and the TCP initiator.

What cannot work

These are not missing features, they are consequences of running as an ordinary app on a stock phone:

Where a protocol is blocked only by the port number, the same workaround as elsewhere applies: run it on a high port and point clients at that port. It works for anything whose client lets you choose the port, which rules out Windows file sharing but not much else.

Port forwarding on AVF

On the AVF backend, port forwarding is implemented over vsock rather than SLIRP hostfwd. Each rule creates a listener on 0.0.0.0:hostPort on the Android side, which is bridged to a vsock port on the guest via the podroid-vsock-agent service. The privileged-port limit applies here too, for the same reason.

UDP on AVF

UDP forwarding works on both backends. Because vsock is a stream transport, each inbound UDP datagram is length-framed and carried over a per-client vsock connection, then delivered to the guest as a real UDP datagram - so a guest DNS server such as Pi-hole is reachable over UDP. Idle flows are reaped automatically, and the number of concurrent client flows is capped to bound resource use.

The privileged-port limit applies at both ends: an unprivileged Android app can't bind a host port below 1024, and rootless Podman can't publish a privileged port inside the guest either. So run a DNS server like Pi-hole on a high port - for example host 5353/udp to guest 5353/udp - and point clients at <phone-ip>:5353.

Podroid is free software (GPL). Docs for v1.2.8. Found something inaccurate? Open an issue.