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.

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/kvmon 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.

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

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

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.

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.