A Proxmox Lab That Runs Without Your Laptop

Run an Android Emulator, Windows desktop, Kali lab, and Herdr agent on Proxmox instead of squeezing them onto a laptop. The host supplies the CPU, RAM, and storage; the guests keep running when the laptop disconnects or shuts down.

Animated diagram: a workstation connects through Tailscale to four workloads on a Proxmox host: Android, Windows, Kali, and Herdr.

Why run these remotely?

The laptop becomes a client, not the machine doing the work. A Proxmox host can allocate more cores, RAM, and disk to a workload than the laptop has available, and can run several isolated workloads at once. The host must remain powered on, but the laptop need not: close it, disconnect from the network, or switch devices without shutting down the guests.

  • Android: Keep a hardware-accelerated Google AVD running without consuming the laptop’s RAM or requiring a local emulator boot for each test.
  • Windows: Reach a full Windows desktop from a lightweight client while its VM remains available for long-running tasks.
  • Kali: Keep tools and a lab network in a separate VM; reconnect to the same environment later.
  • Herdr: Agents and terminal panes keep running in the VM even while the laptop is off. Reconnect to the same session later.

Prerequisites

  • Proxmox VE 9 on x86-64 with AMD-V or VT-x enabled. Check test -r /dev/kvm && ls -l /dev/kvm on the host.
  • Enough RAM and thin-pool space for all running guests. The example uses 8 GiB for Android, 4 GiB each for Windows and Kali, and 2 GiB for Herdr, before host overhead. Thin-provisioned disks can still fill their pool.
  • A bridge such as vmbr0, working DHCP/DNS, an SSH key for Proxmox, and Tailscale on Proxmox and the workstation. Do not expose the Proxmox UI, ADB, RDP, or Herdr to the public Internet.
  • Official installation media and a Windows Pro/Enterprise/Education license. Windows Home cannot host incoming RDP.

On the workstation, define a key-authenticated SSH alias in ~/.ssh/config (replace the address and key path):

Host pve
    HostName <PVE_TS_IP>
    User root
    IdentityFile ~/.ssh/proxmox
    IdentitiesOnly yes

Run qm list, pct list, and pvesm status first. The IDs below—100, 101, 103, and 200—are examples. Pick unused IDs and substitute your own bridge, storage, addresses, usernames, and public keys. Commands marked “host” run on Proxmox; commands marked “guest” run inside that guest. Reserve DHCP leases if you need stable LAN addresses.

workstation ── Tailscale/SSH ── Proxmox (vmbr0)
                                  ├─ CT 200: Debian + /dev/kvm + Google Emulator
                                  ├─ VM 101: Windows 11 + RDP
                                  ├─ VM 100: Kali + SSH/lab network
                                  └─ VM 103: Debian + Herdr server

1. Official Android Emulator in an unprivileged container

The Android Emulator is itself a QEMU virtual machine. A Proxmox VM running it would normally require nested virtualization. Instead, place the emulator process in an unprivileged LXC and pass through only the host’s /dev/kvm. The container shares the host kernel; the Android guest uses KVM directly. This is not VM-grade isolation for hostile APKs.

Animated diagram: the unprivileged emulator container receives the host KVM device and runs an Android AVD without a nested Proxmox VM.

Create the container

On the host, find the host KVM group ID and a Debian 13 template name:

stat -c '%G %g %a' /dev/kvm
pveam available --section system | grep debian-13
pveam download local debian-13-standard_13.6-1_amd64.tar.zst
KVM_GID=$(stat -c %g /dev/kvm)
pct create 200 local:vztmpl/debian-13-standard_13.6-1_amd64.tar.zst \
  --hostname android-emulator --unprivileged 1 \
  --cores 4 --memory 8192 --swap 1024 --rootfs local-lvm:64 \
  --net0 name=eth0,bridge=vmbr0,ip=dhcp,firewall=1 \
  --dev0 path=/dev/kvm,gid="$KVM_GID",mode=0660 \
  --onboot 1 --ostype debian
pct start 200
pct exec 200 -- ls -l /dev/kvm

Use the actual template version shown by pveam if it changed. Do not enable privileged mode, nesting=1, broad device cgroups, or an unconfined AppArmor profile merely to get KVM access. The container’s kvm GID can differ from the host GID because of user-namespace mapping.

Install Google’s SDK and create an AVD

Enter the container with pct enter 200. The following commands run inside CT 200 as root, except where runuser selects the dedicated emulator account:

apt-get update
apt-get install -y --no-install-recommends ca-certificates curl unzip \
  openjdk-21-jre-headless libglib2.0-0 libnss3 libdbus-1-3 \
  libfontconfig1 libx11-6 libx11-xcb1 libxcb1 libxcomposite1 \
  libxcursor1 libxi6 libxtst6 libxrandr2 libxrender1 \
  libxkbcommon0 libxkbcommon-x11-0 libpulse0 libasound2t64 \
  libopengl0 libgl1 libegl1 libgbm1 libvulkan1 openssh-server nftables
useradd -m -s /bin/bash -G kvm emulator
install -d -o emulator -g emulator /opt/android-sdk /opt/android-sdk/cmdline-tools
curl -fL https://dl.google.com/android/repository/commandlinetools-linux-15859902_latest.zip \
  -o /tmp/android-commandline-tools.zip
printf '%s  %s\n' \
  '4e4c464f145a7512b57d088ac6c278c03c9eea610886b35a5e0804e74eedf583' \
  /tmp/android-commandline-tools.zip | sha256sum -c -
unzip -q /tmp/android-commandline-tools.zip -d /opt/android-sdk/cmdline-tools
mv /opt/android-sdk/cmdline-tools/cmdline-tools /opt/android-sdk/cmdline-tools/latest
chown -R emulator:emulator /opt/android-sdk

That ZIP and hash are a pinned, tested example; use a newer version and its verified checksum if Google changes the download. Run the SDK commands with an explicit home and SDK path:

export HOME=/home/emulator ANDROID_HOME=/opt/android-sdk ANDROID_SDK_ROOT=/opt/android-sdk
cd /home/emulator
yes | runuser -u emulator -- env HOME="$HOME" ANDROID_HOME="$ANDROID_HOME" \
  ANDROID_SDK_ROOT="$ANDROID_SDK_ROOT" \
  /opt/android-sdk/cmdline-tools/latest/bin/sdkmanager --licenses
runuser -u emulator -- env HOME="$HOME" ANDROID_HOME="$ANDROID_HOME" \
  ANDROID_SDK_ROOT="$ANDROID_SDK_ROOT" \
  /opt/android-sdk/cmdline-tools/latest/bin/sdkmanager --install \
  'platform-tools' 'emulator' 'platforms;android-36' \
  'system-images;android-36;google_apis;x86_64'
printf 'no\n' | runuser -u emulator -- env HOME="$HOME" \
  ANDROID_HOME="$ANDROID_HOME" ANDROID_SDK_ROOT="$ANDROID_SDK_ROOT" \
  /opt/android-sdk/cmdline-tools/latest/bin/avdmanager create avd \
  -n Pixel_9_API_36 -k 'system-images;android-36;google_apis;x86_64' \
  -d pixel_9 -c 4096M
runuser -u emulator -- /opt/android-sdk/emulator/emulator -accel-check

This creates a Google APIs Android 16/API 36 x86-64 Pixel profile. Google APIs and Google Play images are different packages; select the image your app requires. -accel-check must report usable KVM before continuing.

Create /etc/systemd/system/android-emulator.service in the container:

[Unit]
Description=Google Android Emulator (Pixel 9 / API 36)
After=network-online.target
Wants=network-online.target

[Service]
Type=simple
User=emulator
Group=emulator
SupplementaryGroups=kvm
WorkingDirectory=/home/emulator
Environment=HOME=/home/emulator
Environment=ANDROID_HOME=/opt/android-sdk
Environment=ANDROID_AVD_HOME=/home/emulator/.android/avd
Environment=QT_QPA_PLATFORM=offscreen
ExecStart=/opt/android-sdk/emulator/emulator -avd Pixel_9_API_36 -port 5554 -no-window -no-audio -gpu software -no-snapshot -no-boot-anim -accel on -no-metrics
Restart=on-failure
RestartSec=15
TimeoutStopSec=120

[Install]
WantedBy=multi-user.target
systemctl daemon-reload
systemctl enable --now android-emulator.service
journalctl -u android-emulator -b --no-pager -n 50
ss -lnt | grep -E ':555[45] '

The verified setup listened on container loopback ports 5554/5555. Check your own listener; do not assume a headless emulator is firewalled by default.

Connect Android Studio without exposing ADB

In the dedicated container, create a key-only SSH forwarding account. Replace the sample public key below with the workstation’s public key, never its private key:

useradd -m -s /usr/sbin/nologin adb-tunnel
install -d -m 0700 -o adb-tunnel -g adb-tunnel /home/adb-tunnel/.ssh
printf '%s\n' 'restrict,port-forwarding,permitopen="127.0.0.1:5555" ssh-ed25519 REPLACE_WITH_PUBLIC_KEY workstation' \
  >> /home/adb-tunnel/.ssh/authorized_keys
chown adb-tunnel:adb-tunnel /home/adb-tunnel/.ssh/authorized_keys
chmod 0600 /home/adb-tunnel/.ssh/authorized_keys

In /etc/ssh/sshd_config.d/90-adb-tunnel.conf:

PasswordAuthentication no
KbdInteractiveAuthentication no
PermitRootLogin no
AllowUsers adb-tunnel
Match User adb-tunnel
    AllowTcpForwarding local
    PermitOpen 127.0.0.1:5555
    AllowAgentForwarding no
    X11Forwarding no
    PermitTTY no

ssh -N forwarding does not require a login shell. Validate with sshd -t and restart ssh. Allow container SSH only from the Proxmox bridge IP in the container firewall; leave ADB and emulator-console ports closed. For this dedicated CT, use an nftables input policy that drops unsolicited inbound traffic. Replace <PVE_BRIDGE_IP> before loading; merge with any existing rules rather than overwriting a shared policy:

# /etc/nftables.conf inside the dedicated container
table inet android_ct {
    chain input {
        type filter hook input priority 10; policy drop;
        iifname "lo" accept
        ct state established,related accept
        ip saddr <PVE_BRIDGE_IP> tcp dport 22 accept
        udp sport 67 udp dport 68 accept
        ip protocol icmp accept
        ip6 nexthdr ipv6-icmp accept
    }
}

Validate with nft -c -f /etc/nftables.conf, then systemctl enable --now nftables. Do this from the Proxmox console so a mistaken rule cannot strand SSH. firewall=1 on a Proxmox NIC is not a substitute for checking whether the host firewall is actually enabled.

On the workstation, with pve defined as a key-authenticated SSH alias for Proxmox:

ssh -J pve -N -o ExitOnForwardFailure=yes \
  -L 127.0.0.1:15555:127.0.0.1:5555 adb-tunnel@<CT_LAN_IP>
# In another terminal, using the same adb installation Android Studio uses:
adb connect 127.0.0.1:15555
adb devices -l
adb -s 127.0.0.1:15555 shell getprop ro.build.version.release
scrcpy --serial 127.0.0.1:15555 --no-audio

Android Studio’s device selector recognized the TCP-connected AVD, but IDE Run/Debug with an app was not tested. A headless remote AVD need not appear in Studio’s embedded Running Devices panel; scrcpy supplies interactive video and input. If the tunnel drops, reopen it and repeat adb connect. On one Nix workstation, a blank scrcpy window required a native SDL2 rather than sdl2-compat; this was a client rendering problem, not an absent Android display.

2. Windows VM with private RDP

Animated diagram: RDP reaches a Windows VM through a Tailscale-only Proxmox relay while the VM keeps running after the workstation disconnects.

Create a Windows 11 Pro VM in the Proxmox UI: OVMF/UEFI, a TPM 2.0 state disk, 4 cores, 4 GiB RAM or more, and a 64 GiB disk. Attach an official Windows ISO and the VirtIO driver ISO. e1000 networking and SATA storage work without loading VirtIO drivers during setup; VirtIO network/storage are preferable once their drivers are installed. Put the NIC on vmbr0, finish Windows setup, install the QEMU guest agent, and enable it in the VM Options. Provide a valid Windows license.

In Windows, open Settings → System → Remote Desktop, enable it, leave Network Level Authentication on, and grant access only to intended accounts with strong passwords. Confirm from Proxmox that the guest answers on TCP 3389 before adding a relay. A working bridge and DHCP lease are separate requirements; if the VM has only a 169.254.x.x address, fix DHCP/router connectivity before assuming a static address will provide Internet.

To avoid Tailscale inside Windows and avoid publishing RDP, use Proxmox as a narrow TCP relay. Install socat on the host, find the VM’s IPv6 link-local address from ipconfig /all in Windows, and confirm Proxmox can reach [fe80::…%vmbr0]:3389. Replace all placeholders in this systemd unit, including the Tailscale source IP. In systemd files, write a literal % as %%:

# /etc/systemd/system/windows-rdp-relay.service on Proxmox
[Unit]
Description=RDP from one Tailscale workstation to Windows
After=network-online.target tailscaled.service
Wants=network-online.target

[Service]
Type=simple
DynamicUser=yes
ExecStart=/usr/bin/socat TCP4-LISTEN:3389,bind=<PVE_TS_IP>,reuseaddr,fork,range=<LAPTOP_TS_IP>/32 TCP6:[fe80::GUEST_LINK_LOCAL%%vmbr0]:3389
Restart=always
RestartSec=10
NoNewPrivileges=yes
ProtectSystem=strict
ProtectHome=yes
CapabilityBoundingSet=
RestrictAddressFamilies=AF_INET AF_INET6

[Install]
WantedBy=multi-user.target
# Proxmox host, after replacing placeholders:
apt-get install socat
systemctl daemon-reload
systemctl enable --now windows-rdp-relay.service
ss -lnt | grep ':3389 '
# Workstation; enter the password at the RDP prompt:
xfreerdp3 /v:<PVE_TS_IP>:3389 /u:<WINDOWS_USER> /cert:tofu +dynamic-resolution

The relay binds only to Proxmox’s Tailscale address and accepts only the specified workstation. Windows still authenticates the RDP session. If a guest’s link-local IPv6 or the workstation’s Tailscale address changes, update the unit. Do not use /p:<password> on a shared command line. A localhost-only SSH -L tunnel is an alternative if you do not want a persistent listener.

For a remote PowerShell shell, Windows WinRM can be reached through a separate localhost-only SSH tunnel to the guest’s port 5985. Restrict the Windows firewall rule to Proxmox’s bridge addresses and use authenticated, message-encrypted WinRM; do not bind a WinRM relay to the whole tailnet. Enable-PSRemoting configures a listener, service, and firewall rules. Local administrator authentication may separately require a LocalAccountTokenFilterPolicy change, which affects other network logons; assess that security trade-off before changing it.

3. Kali VM and an optional routed lab

Animated diagram: a workstation reaches Kali through Proxmox, with an optional routed lab subnet behind Kali.

Download the official Kali installer ISO and verify its published checksum/signature. Create a QEMU VM with a disk of at least 20 GiB (40 GiB leaves room for tools), 2 or more cores, at least 2 GiB RAM (4 GiB for a desktop), and a vmbr0 NIC. Install through the Proxmox console, update packages, set a unique password, and install/enable SSH if remote terminal access is wanted:

# Kali guest
sudo apt update
sudo apt install openssh-server qemu-guest-agent
sudo systemctl enable --now ssh qemu-guest-agent
ip -4 addr show

The Kali VM is a full VM, not a privileged container. Keep pentest traffic confined to networks you own or have permission to assess. The simplest workstation path needs no advertised subnet and no inbound LAN port on Proxmox:

ssh -J pve <KALI_USER>@<KALI_LAN_IP>

If Kali hosts a separate lab subnet, for example 10.6.6.0/24 with Kali at 10.6.6.1, configure that subnet inside Kali first and confirm Proxmox can reach 10.6.6.1. The tested host routes it through Kali’s IPv6 link-local address on vmbr0; a different lab topology needs a different next hop. Before enabling forwarding, restrict it with a host firewall policy for the intended source and destination. On Proxmox, enable IPv4 forwarding (and persist net.ipv4.ip_forward=1 in your /etc/sysctl.d/ configuration):

sysctl -w net.ipv4.ip_forward=1
ip route replace 10.6.6.0/24 via inet6 fe80::KALI_LINK_LOCAL dev vmbr0
ping -c 2 10.6.6.1
# Include any existing advertised routes in the comma-separated list:
tailscale set --advertise-routes=10.6.6.0/24

Approve 10.6.6.0/24 under Machines → Proxmox → Subnets → Edit in the Tailscale admin console. Grant the intended user/device access to the CIDR in tailnet policy. On a Linux workstation:

sudo tailscale set --accept-routes=true
ip route show table 52 | grep 10.6.6
ping 10.6.6.1
ssh <KALI_USER>@10.6.6.1

Route approval, tailnet ACLs, Proxmox’s forwarding firewall, and Kali’s own firewall are four different controls. A narrow host rule for a single workstation can be placed before Tailscale’s general forwarding chain, then persisted in a firewall configuration or a systemd unit:

# Proxmox host; replace the address and persist the rule across reboots.
iptables -I FORWARD 1 -i tailscale0 -d 10.6.6.0/24 \
  ! -s <LAPTOP_TS_IP>/32 -j DROP

This rule only drops non-matching sources arriving on tailscale0; it is not a complete forwarding policy and does not itself allow the permitted source. Check the remaining FORWARD rules and other ingress paths.

In one deployment, the route was approved and installed, but an old Proxmox FORWARD rule still dropped packets from the workstation’s new Tailscale IP. Diagnose in order: ip route get 10.6.6.1 on the workstation, tcpdump -ni tailscale0 host 10.6.6.1 on Proxmox, then tcpdump -ni vmbr0 host 10.6.6.1 and iptables -L FORWARD -v -n --line-numbers. If packets enter tailscale0 but not vmbr0, inspect the host forwarding rules; do not restart Tailscale just to refresh a route. Restrict forwarding to the intended source, and persist the forwarding setting, host route, and firewall rule across reboot. Tailscale’s default subnet-route SNAT usually avoids a separate return route; if SNAT is disabled, add one explicitly.

4. Debian VM for a persistent Herdr server

Herdr owns terminal sessions on the machine doing the work. The client can detach over SSH while the server and its panes remain in the VM. A VM provides a separate kernel, disk, credentials, and resource limit for coding agents. One working allocation is 2 vCPUs, 2 GiB RAM, and a 32 GiB disk; increase it for large builds.

Animated diagram: a Herdr client detaches through an SSH jump while its remote server and panes remain in the Debian VM.

Use an official Debian 13 cloud image and Proxmox Cloud-Init. On the host, after selecting an unused VM ID and copying in your workstation’s public SSH key:

curl -fL https://cloud.debian.org/images/cloud/trixie/latest/debian-13-genericcloud-amd64.qcow2 \
  -o /root/debian-13-genericcloud-amd64.qcow2
qm create 103 --name herdr-agent --cores 2 --memory 2048 \
  --net0 virtio,bridge=vmbr0 --scsihw virtio-scsi-pci \
  --ostype l26 --agent enabled=1 --onboot 1
qm set 103 --scsi0 local-lvm:0,import-from=/root/debian-13-genericcloud-amd64.qcow2
qm resize 103 scsi0 32G
qm set 103 --ide2 local-lvm:cloudinit --boot order=scsi0 \
  --serial0 socket --vga serial0 --ciuser agent \
  --sshkey /root/herdr-vm.pub --ipconfig0 ip=dhcp
qm start 103

Check the downloaded image’s published checksum before using it. Cloud-Init’s --sshkey reads a file on Proxmox, not on the workstation. Find the DHCP lease from the router or VM console, then connect with ssh -J pve -i ~/.ssh/herdr-vm agent@<HERDR_LAN_IP>. Verify the SSH host key from the console before trusting a reused DHCP address. Install qemu-guest-agent inside the guest and enable it; then qm agent 103 network-get-interfaces can report its current address.

Install Herdr as the unprivileged agent user from an official release or the official installer. If using the installer, download and inspect it before running it:

# Herdr VM, logged in as agent
curl -fsSLo /tmp/herdr-install.sh https://herdr.dev/install.sh
less /tmp/herdr-install.sh
sh /tmp/herdr-install.sh
~/.local/bin/herdr --version

Run the server as a user service rather than root. As agent, create ~/.config/systemd/user/herdr.service:

[Unit]
Description=Herdr server
After=network-online.target
Wants=network-online.target

[Service]
Type=simple
Environment=PATH=/home/agent/.local/bin:/usr/local/bin:/usr/bin:/bin
ExecStart=/home/agent/.local/bin/herdr server
Restart=on-failure
RestartSec=5

[Install]
WantedBy=default.target
# Guest: enable lingering with sudo, then as agent:
sudo loginctl enable-linger agent
systemctl --user daemon-reload
systemctl --user enable --now herdr.service
systemctl --user status herdr.service

On the workstation, add another entry to ~/.ssh/config:

Host herdr-vm
    HostName <HERDR_LAN_IP>
    User agent
    IdentityFile ~/.ssh/herdr-vm
    IdentitiesOnly yes
    ProxyJump pve

Verify ssh herdr-vm first. Install the Herdr client locally and run either:

herdr --remote herdr-vm        # attach one remote session
herdr machine add herdr-vm     # save it alongside local/other machines
herdr                       # open the multi-machine UI

Herdr may ask before installing or replacing an incompatible remote binary. Do not approve replacement of a running server until you understand whether its pane processes will stop. Ctrl+B, then Q, detaches the client; the remote server keeps panes alive. After a VM reboot, the original processes do not survive: Herdr restores saved layout and can resume supported agent sessions, not arbitrary running programs. Back up the guest home directory and disk separately.

Operations that apply to all four

# Proxmox host
pct status 200 && pct exec 200 -- systemctl is-active android-emulator
qm status 100 && qm status 101 && qm status 103
pvesm status
# Point this at a backup-capable storage target with sufficient space:
vzdump 200 100 101 103 --storage <BACKUP_STORAGE> --mode snapshot --compress zstd

A Proxmox snapshot is not a backup: it depends on the same storage pool. Install and run the guest agent where supported, monitor thin-pool free space, schedule vzdump to separate storage, and test a restore. Keep service listeners bound to Tailscale or loopback and update address-based firewall rules when DHCP or Tailscale IPs change.

References: Proxmox Cloud-Init, Kali on Proxmox, Windows RDP requirements, Tailscale route injection, Herdr remote attach.