Post

Deploy Android Redroid with droidVNC-NG and noVNC

Run Android in Docker with Redroid, control it using droidVNC-NG, expose noVNC through Traefik and Authentik, and keep raw VNC tailnet-only.

Deploy Android Redroid with droidVNC-NG and noVNC

Redroid runs Android in a Linux container. It is useful when you want a disposable Android environment on a server, but browser control can be tricky. The stable pattern documented here is:

  • Redroid provides the Android runtime.
  • droidVNC-NG runs inside Android and handles screen capture plus input.
  • noVNC exposes the VNC session in a browser.
  • Traefik routes the web UI through HTTPS and Authentik.
  • Raw VNC is bound only to a private tailnet IP.

This service has since been retired from the live homelab, but the final working design is documented because it is reusable.

All domains, passwords, and tailnet IPs below are placeholders. Replace android.example.com, 100.64.0.2, and the VNC password with your own values.

Do not publish a raw VNC port on 0.0.0.0. Use a VNC password and bind native VNC to localhost or a private tailnet address only.


What this service does

Route or access pattern:

1
2
3
https://android.example.com          -> Authentik -> noVNC browser UI
100.64.0.2:5901                     -> optional native VNC on private tailnet only
127.0.0.1:5555                      -> local ADB only

Main components:

1
Redroid Android container, droidVNC-NG APK, noVNC, websockify, Docker Compose, Traefik, Authentik, Headscale/Tailscale.

Runtime flow:

1
2
3
4
5
6
Browser
  -> Traefik HTTPS
  -> Authentik
  -> noVNC/websockify on port 6080
  -> droidVNC-NG inside Android on port 5901
  -> Redroid screen and input

Why VNC instead of Webscreen or scrcpy-over-WebRTC

Redroid on ARM64 can expose edge cases in MediaCodec/scrcpy streaming paths. In this deployment, Webscreen and scrcpy-style WebRTC pages could load but produced black video or unstable frame negotiation. Screenshot polling worked, but it was too slow for real use.

VNC was more practical:

  • droidVNC-NG captures the Android screen inside the Android environment.
  • noVNC gives browser access without requiring a native VNC app.
  • Native VNC remains available on the tailnet for faster clients.
  • The browser route stays behind SSO.


Folder layout

Use one service directory.

1
2
3
4
5
6
7
8
9
/home/ubuntu/android-redroid/
├── docker-compose.yml
├── novnc/
│   └── Dockerfile
├── vnc/
│   └── droidvnc-ng.apk
├── secrets/
│   └── droidvnc.env
└── data/                  # Redroid persistent Android data

Create it:

1
2
mkdir -p /home/ubuntu/android-redroid/{novnc,vnc,secrets,data}
cd /home/ubuntu/android-redroid

Download droidVNC-NG

Edit files under /home/ubuntu/android-redroid.

Download the droidVNC-NG APK into /home/ubuntu/android-redroid/vnc/droidvnc-ng.apk.

1
2
cd /home/ubuntu/android-redroid
curl -L   -o vnc/droidvnc-ng.apk   https://github.com/bk138/droidVNC-NG/releases/download/v2.19.0/droidVNC-NG-2.19.0.apk

If that release URL changes, download the latest APK from the droidVNC-NG GitHub releases page and keep the filename consistent.


VNC secret file

Edit /home/ubuntu/android-redroid/secrets/droidvnc.env.

DROIDVNC_PASSWORD=<strong-vnc-password>

Restrict permissions:

1
chmod 600 /home/ubuntu/android-redroid/secrets/droidvnc.env

noVNC image

Edit /home/ubuntu/android-redroid/novnc/Dockerfile.

1
2
3
4
5
6
7
8
9
FROM python:3.12-alpine

RUN apk add --no-cache curl tar bash     && pip install --no-cache-dir websockify==0.12.0     && mkdir -p /opt/novnc     && curl -L --fail https://github.com/novnc/noVNC/archive/refs/tags/v1.5.0.tar.gz       | tar xz --strip-components=1 -C /opt/novnc     && printf '%s
'       '<!doctype html>'       '<meta charset="utf-8">'       '<title>Android VNC</title>'       '<meta http-equiv="refresh" content="0; url=/vnc.html?autoconnect=1&resize=scale&reconnect=1">'       '<script>location.replace("/vnc.html?autoconnect=1&resize=scale&reconnect=1")</script>'       '<a href="/vnc.html?autoconnect=1&resize=scale&reconnect=1">Open Android VNC</a>'       > /opt/novnc/index.html

EXPOSE 6080

ENTRYPOINT ["websockify"]
CMD ["--web", "/opt/novnc", "6080", "redroid:5901"]

This makes the root of the web service open noVNC directly.


Docker Compose stack

Edit /home/ubuntu/android-redroid/docker-compose.yml.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
services:
  redroid:
    image: redroid/redroid:12.0.0_64only_mindthegapps
    container_name: android-redroid
    restart: unless-stopped
    privileged: true
    cpus: "4.0"
    mem_limit: 8g
    volumes:
      - /home/ubuntu/android-redroid/data:/data
    ports:
      - "127.0.0.1:5555:5555"
    networks:
      - default
    command:
      - androidboot.redroid_width=1080
      - androidboot.redroid_height=2280
      - androidboot.redroid_dpi=420
      - androidboot.redroid_fps=30
      - androidboot.use_memfd=true
      - androidboot.redroid_gpu_mode=guest
      - ro.product.model=Samsung Galaxy S10
      - ro.product.brand=samsung
      - ro.product.manufacturer=samsung
      - ro.product.device=beyond1
      - ro.product.name=beyond1lte

  android-novnc:
    build:
      context: ./novnc
    image: local/android-novnc:latest
    container_name: android-novnc
    restart: unless-stopped
    depends_on:
      - redroid
    networks:
      - default
      - proxy
    labels:
      - "traefik.enable=true"
      - "traefik.docker.network=proxy"

      - "traefik.http.middlewares.android-https.redirectscheme.scheme=https"
      - "traefik.http.routers.android.entrypoints=http"
      - "traefik.http.routers.android.rule=Host(`android.example.com`)"
      - "traefik.http.routers.android.middlewares=android-https"

      - "traefik.http.routers.android-secure.entrypoints=https"
      - "traefik.http.routers.android-secure.rule=Host(`android.example.com`)"
      - "traefik.http.routers.android-secure.tls=true"
      - "traefik.http.routers.android-secure.tls.certresolver=cloudflare"
      - "traefik.http.routers.android-secure.middlewares=authentik@docker"
      - "traefik.http.routers.android-secure.service=android-vnc"
      - "traefik.http.services.android-vnc.loadbalancer.server.port=6080"

  android-vnc-tailnet:
    image: alpine/socat:latest
    container_name: android-vnc-tailnet
    restart: unless-stopped
    depends_on:
      - redroid
    command: "tcp-listen:5901,fork,reuseaddr tcp-connect:redroid:5901"
    ports:
      - "100.64.0.2:5901:5901"
    networks:
      - default

networks:
  proxy:
    external: true

Replace 100.64.0.2 with your server’s private tailnet IP. Remove android-vnc-tailnet entirely if you only want browser access.


Start Redroid

Run from /home/ubuntu/android-redroid:

1
2
cd /home/ubuntu/android-redroid
docker compose up -d redroid

Wait for Android to boot:

1
2
3
adb connect 127.0.0.1:5555
adb -s 127.0.0.1:5555 wait-for-device
adb -s 127.0.0.1:5555 shell getprop sys.boot_completed

Expected output:

1
1

Install droidVNC-NG into Android

Run:

1
adb -s 127.0.0.1:5555 install -r /home/ubuntu/android-redroid/vnc/droidvnc-ng.apk

Confirm package name:

1
adb -s 127.0.0.1:5555 shell pm list packages | grep droidvnc

Expected package:

1
package:net.christianbeier.droidvnc_ng

Enable droidVNC-NG permissions

Android remote-control apps usually need accessibility and screen-capture approval. For a server-side Redroid instance, preseed as much as possible with ADB.

Run:

1
2
3
4
5
adb -s 127.0.0.1:5555 shell settings put secure enabled_accessibility_services   net.christianbeier.droidvnc_ng/.InputService

adb -s 127.0.0.1:5555 shell settings put secure accessibility_enabled 1

adb -s 127.0.0.1:5555 shell appops set   net.christianbeier.droidvnc_ng PROJECT_MEDIA allow || true

Check accessibility:

1
adb -s 127.0.0.1:5555 shell settings get secure enabled_accessibility_services

Configure droidVNC-NG defaults

Create defaults inside Android:

1
adb -s 127.0.0.1:5555 shell mkdir -p   /sdcard/Android/data/net.christianbeier.droidvnc_ng/files

Create /tmp/droidvnc-defaults.json on the host:

1
2
3
4
5
6
7
{
  "port": 5901,
  "password": "<strong-vnc-password>",
  "scaling": 0.6,
  "view_only": false,
  "show_pointers": true
}

Push it into Android:

1
adb -s 127.0.0.1:5555 push /tmp/droidvnc-defaults.json   /sdcard/Android/data/net.christianbeier.droidvnc_ng/files/defaults.json

Start droidVNC-NG service

Start the service with ADB:

1
adb -s 127.0.0.1:5555 shell am start-foreground-service   -n net.christianbeier.droidvnc_ng/.MainService

Verify VNC responds from inside the Docker network:

1
docker run --rm --network android-redroid_default alpine:3.20   sh -lc 'apk add --no-cache busybox-extras >/dev/null && nc -vz redroid 5901'

Expected:

1
redroid (redroid:5901) open

You can also check the RFB handshake:

1
docker run --rm --network android-redroid_default alpine:3.20   sh -lc 'apk add --no-cache busybox-extras >/dev/null && printf "" | nc redroid 5901 | head -c 12'

Expected:

1
RFB 003.008

Start noVNC and tailnet VNC

Run:

1
2
cd /home/ubuntu/android-redroid
docker compose up -d --build

Check containers:

1
docker compose ps

Expected:

1
2
3
android-redroid
android-novnc
android-vnc-tailnet

Verify browser access

Open:

1
https://android.example.com

Expected browser flow:

1
Authentik login -> noVNC page -> VNC password prompt -> Android screen

If noVNC shows a WebSocket error, make sure the Traefik router sends all paths for android.example.com to android-novnc and that websockify listens on port 6080.


Verify native VNC over tailnet

From a device connected to your tailnet, connect a VNC client to:

1
100.64.0.2:5901

Use the VNC password from /home/ubuntu/android-redroid/secrets/droidvnc.env.

From the server, check the bind:

1
ss -tulpn | grep ':5901'

It should bind to the private tailnet IP, not public 0.0.0.0.


Add to Homepage dashboard

Edit /home/ubuntu/homepage/config/services.yaml.

1
2
3
4
5
6
- Remote Access:
    - Android VNC:
        icon: android.png
        href: https://android.example.com
        description: Browser VNC control for a Redroid Android instance
        siteMonitor: https://android.example.com

Restart Homepage:

1
docker restart homepage

Google Play verification note

If Google Play accepts the email and password but asks for phone verification or “Verify it’s you,” that is a Google account security challenge. It is not a Redroid, GApps, noVNC, droidVNC-NG, or Traefik issue. The account owner must complete the verification flow.


Retiring the service while keeping docs

If you no longer need Android, keep this documentation and remove the live service.

Run:

1
2
3
cd /home/ubuntu/android-redroid
docker compose down --remove-orphans
rm -rf /home/ubuntu/android-redroid

Then remove the Homepage entry from /home/ubuntu/homepage/config/services.yaml and restart Homepage:

1
docker restart homepage

Troubleshooting

Redroid does not boot

Check logs:

1
2
3
docker logs --tail=200 android-redroid
adb connect 127.0.0.1:5555
adb -s 127.0.0.1:5555 shell getprop sys.boot_completed

If the host has no /dev/kvm, avoid KVM-based Android emulator images and use Redroid-compatible images instead.

noVNC connects but screen is black

Check droidVNC-NG inside Android:

1
adb -s 127.0.0.1:5555 shell dumpsys activity services   net.christianbeier.droidvnc_ng | head -80

Make sure screen capture permission is active. If needed, open the droidVNC-NG app inside Android once and approve capture.

Keyboard or touch input does not work

Check accessibility:

1
2
adb -s 127.0.0.1:5555 shell settings get secure accessibility_enabled
adb -s 127.0.0.1:5555 shell settings get secure enabled_accessibility_services

The droidVNC-NG input service should be enabled.

Raw VNC is reachable publicly

This is unsafe. Fix the port bind in /home/ubuntu/android-redroid/docker-compose.yml:

1
2
ports:
  - "100.64.0.2:5901:5901"

Do not use:

1
2
ports:
  - "5901:5901"

The second form binds on public interfaces.


Security checklist

  • noVNC route protected by Authentik.
  • VNC password set.
  • Raw VNC bound to private tailnet IP only.
  • ADB bound to 127.0.0.1 only.
  • No public 0.0.0.0:5901 listener.
  • Homepage entry removed when service is retired.
  • Android data directory deleted when no longer needed.
This post is licensed under CC BY 4.0 by the author.