I wanted a way to reach a desktop environment back at home from both my personal and work laptops. I didn’t want to install anything additional on the laptops, so anything requiring a VPN client, an RDP client, or really any kind of agent was a non-starter. Whatever I built had to work through the web browser alone.
I also wanted it to be properly secure. “Just open RDP to the internet” was never going to cut it, and even traditional VPN-and-RDP feels heavier than it needs to be for what is essentially “let me see a desktop in a browser tab.”
This is how I got there, including the bits where things went wrong — the failure modes tend to be more interesting than the happy path.
The architecture
After weighing a few options (more on those later), I landed on this:
- A dedicated Windows 11 VM on Hyper-V as the remote desktop target — always on, isolated, disposable
- Apache Guacamole running in Docker as a clientless remote desktop gateway, rendering RDP sessions in the browser via HTML5
- Cloudflare Tunnel providing the path from the internet to my Docker host, with no inbound firewall ports opened
- Cloudflare Access sitting in front of everything, requiring email-OTP authentication before traffic ever reaches Guacamole
- A dedicated VLAN for the Windows 11 VM, with UniFi firewall rules limiting east-west traffic into the rest of my network
The end result: A Windows 11 desktop accessible from any browser, anywhere, gated by two independent authentication layers, with zero ports exposed on my home firewall.
Why Guacamole over the alternatives
A few options I considered and ruled out:
MeshCentral is a fantastic tool, but it’s a remote management platform — agent-based, broader scope, more about managing fleets of machines than serving up a single always-on desktop. For my use case (one VM, browser access, no agents needed), Guacamole was a better fit. I’ll likely add MeshCentral later for managing physical machines.
RDS Web Client (Microsoft’s HTML5 RDP) would work, but it’s heavier to set up — RD Gateway, RD Connection Broker, RD Web Access roles all need standing up — and it pulls in CAL licensing concerns past the grace period. Overkill for a one-user scenario.
Cloudflare’s native browser-rendered RDP in Zero Trust is the most elegant option but sits behind their paid tier. Worth knowing about if you’re already on a paid plan.
Guacamole won on simplicity, cost (free), self-hosted control, and the fact that it slots cleanly behind Cloudflare Access without any special handling.
The things to skip
A few approaches that look attractive on paper but I’d avoid:
Port-forwarding RDP directly to the VM. The path of least resistance, and a terrible idea. RDP is the most-targeted exposed service on the internet — open the port and you’re under continuous automated brute-force attack from minute one. Strong passwords and NLA are not a sufficient answer for an indefinite exposure window.
Cloudflare Tunnel without Cloudflare Access. A halfway house. You’ve closed the inbound port, which is good, but the public hostname is still resolvable and unauthenticated traffic still reaches Guacamole’s login page. Layering Access in front gives you edge-level rate limiting and OTP, and means an attacker has to clear a layer before they can even see Guacamole exists. Marginal cost on the Free plan is zero.
A single admin account on the VM for both console and RDP work. Convenient — one set of credentials, one mental model. It also means that if the RDP-exposed account is ever compromised, the attacker has admin on the VM rather than a sandboxed user account they need to escalate from. The two-account split (admin via vmconnect, limited rdpuser for RDP) costs about 30 seconds at provisioning time and is worth it.
Why a dedicated VM rather than my actual desktop
My initial thought was “just connect to my desktop.” But desktops aren’t always on, and I really didn’t want to leave a 100W-idling workstation running 24/7 just for the off-chance of remote access. A small VM solves this:
- Always-on, but consumes near-zero resources when idle
- Snapshottable — borked something? Revert in 30 seconds
- Disposable — if the VM is ever compromised through the remote access path, blow it away and rebuild from a template
- Right-sized for the actual workload, not constrained by what the daily-driver desktop needs
I built the VM with 4 vCPUs, 8GB dynamic memory (4GB minimum), an 80GB dynamic VHDX, Generation 2 with vTPM and Secure Boot, and configured it to start automatically with the host. PowerShell scripts are used for deployment — repeatable, version-controlled, and means I can rebuild from scratch in 10 minutes if I ever need to.
Escaping the Microsoft account requirement
Windows 11 OOBE strongly pushes you toward a Microsoft account. For a remote-access VM, this is exactly what you don’t want — coupling your personal identity to a remote-access entry point is a needlessly large blast radius if anything ever goes wrong.
The cleanest way to escape OOBE’s account requirement: disconnect the VM’s network adapter in Hyper-V settings before starting it. With no network, OOBE offers a local-account path. Reconnect after you’re at the desktop. Job done.
(Other methods exist — BypassNRO.cmd from a Shift+F10 console, registry tweaks for newer builds — but the network-disconnect approach works regardless of which Windows 11 release you’re installing and doesn’t depend on Microsoft’s current bypass implementation.)
I created two local accounts: one local admin for occasional console-side admin work via vmconnect, and a separate, limited rdpuser account in the Remote Desktop Users group for the actual remote sessions. Principle of least privilege.
Hardening the Windows side
A post-install PowerShell script handled the rest of the VM setup:
- Time zone, hibernation off, power plan never sleeps
- Created the dedicated
rdpuser, added to Remote Desktop Users - Enabled RDP with NLA
- Disabled the default broad RDP firewall rules and replaced them with a single rule allowing inbound RDP only from the Docker host’s IP
That last point is worth lingering on. The default Windows behaviour is “RDP is allowed from anywhere on the local network.” I scoped it to one specific source — the Docker host running Guacamole. Even if something else on my LAN is ever compromised, it can’t probe RDP on this VM unless it’s that specific machine.
Deploying Guacamole — and the rabbit hole
I run Docker on a dedicated Ubuntu VM, managed via Portainer. The Guacamole stack itself is conceptually simple — three containers (guacd, postgres, guacamole) plus an init script to bootstrap the database schema.
Here’s where the project ate an afternoon I wasn’t expecting it to.
I started with guacamole/guacamole:latest, which at the time of deployment turned out to be the brand-new 1.6.0 release. It came up cleanly, but logging in produced this delightfully unhelpful error:
An error has occurred and this action cannot be completed. If the problem persists, please notify your system administrator or check your system logs.
The system logs revealed the actual issue:
The server requested SCRAM-based authentication, but the password is an empty string.
Guacamole was reaching Postgres but sending an empty password. Several false trails followed:
- Suspected the
=characters in my base64 password were breaking properties-file parsing. Switched to hex-only passwords. Didn’t fix it. - Suspected
GUACAMOLE_HOMEwas pointing at an empty directory. Forced it to empty string. Didn’t fix it. - Inspected the generated
guacamole.propertiesfile and found it contained literally one line:enable-environment-properties: true. Concluded the properties file wasn’t being populated. This was actually correct behaviour for 1.6 — it reads properties from env vars at runtime — but I didn’t know that yet.
The actual root cause: Guacamole 1.6 changed the environment variable naming convention for the database connection. Where 1.5.x used POSTGRES_HOSTNAME, POSTGRES_DATABASE, etc., 1.6 expects POSTGRESQL_HOSTNAME, POSTGRESQL_DATABASE, and so on. The Postgres image itself still uses POSTGRES_*, so you end up with a mixed naming scheme in the same compose file:
postgres:
environment:
POSTGRES_DB: guacamole_db # Postgres image convention
POSTGRES_USER: guacamole_user
POSTGRES_PASSWORD: "..."
guacamole:
environment:
POSTGRESQL_HOSTNAME: postgres # Guacamole 1.6 convention
POSTGRESQL_DATABASE: guacamole_db
POSTGRESQL_USER: guacamole_user
POSTGRESQL_PASSWORD: "..."
Once the env vars matched what 1.6 actually looks for, authentication started working. (And then immediately broke again because I’d swapped the Postgres image from 15-alpine to 16-alpine somewhere along the way, and Postgres very deliberately refuses to start on a data directory created by a different major version. Nuked the volume, re-initialised, finally clean.)
Lessons from the debugging session
A few things worth taking away from this:
1. Pin your image versions in production-style deployments. I’d been using :latest for everything. When :latest rolled forward to a release with breaking environment-variable changes, I had no way to easily fall back to “what was working yesterday.” Pinning everything to specific versions means upgrades are deliberate, not accidental.
2. Hex passwords beat base64 passwords for anything ending up in a config file. openssl rand -hex 24 gives you 192 bits of entropy with characters that can’t break anything — no =, no +, no /, no shell-significant characters. Worth the slight density loss.
3. Postgres major version upgrades are never automatic. If you change postgres:15-alpine to postgres:16-alpine on a volume that already has data, the new container will refuse to start. The proper migration path is pg_dump from the old, fresh volume on the new, pg_restore into it. For my case the data was throwaway so I just nuked the volume.
4. “An error has occurred” is the worst error message. When you see this kind of generic UI error, the actual cause is always in the container logs. Always.
Cloudflare Tunnel + Cloudflare Access
This part was, by contrast, almost too easy. I’d been expecting the security side to be the fiddly bit.
A cloudflared container runs alongside Guacamole on the same Docker network. It makes outbound-only connections to Cloudflare’s edge — no inbound ports, no firewall rules, no port forwarding. Cloudflare hands me a public hostname (desktop.existentia.net) that they route through their network to my tunnel.
In front of that, Cloudflare Access requires authentication before any request even reaches my tunnel. I configured email-OTP as the auth method, with a policy allowing only my specific email address. (You can layer additional rules — country restrictions, device posture checks, time windows — but for a personal use case, email plus a UK country restriction is enough.)
The result is genuinely two-layer auth: Cloudflare Access (email OTP) → Guacamole login (username/password) → RDP session. Compromise of any single layer doesn’t grant access on its own.
The whole tunnel and Access setup took about 15 minutes once I’d got past the Guacamole deployment issues.
One small gotcha worth flagging: when I tested the OTP flow, I didn’t receive the email at first. It eventually arrived — I think the page was hanging on a request that had timed out before I submitted. Just resubmitting the email worked. Worth knowing if you hit it.
Network isolation as defence in depth
After everything was working end-to-end, I moved the Windows 11 VM onto its own VLAN (VLAN 7 in my case). I run UniFi networking and have a few VLANs already, so adding one more was trivial.
The principle: this VM is effectively internet-facing — anyone who clears Cloudflare Access and Guacamole’s auth can reach it. If it’s ever compromised, I want the blast radius contained.
UniFi firewall rules:
- Allow: VLAN 7 → Internet (any) — so the VM can browse and update normally
- Allow: Docker host → VLAN 7 (TCP 3389) — so Guacamole can reach the VM
- Deny: VLAN 7 → Management VLAN, NAS VLAN, Server VLAN — explicit denies for sensitive segments
- Allow specific exceptions for things the VM legitimately needs — DNS to my AdGuard Home server, for instance
The VM functions as a normal Windows desktop with internet access, but if it’s compromised, the attacker can’t pivot to my NAS, my management interfaces, or any of the other things on my home network that I actually care about.
Performance and usability
Over my LAN, the connection is genuinely indistinguishable from a local Windows session — text-mode work, browser usage, light productivity all feel snappy. Through the Cloudflare Tunnel from outside the network, latency goes up by maybe 20-30ms (roughly UK home broadband to Cloudflare’s UK edge to me) but it’s still very usable.
A few Guacamole RDP connection settings make a noticeable difference for browser-rendered sessions:
- Disable wallpaper, theming, full-window drag, desktop composition, menu animations — these all cost bandwidth and don’t add much for a remote session
- Keep font smoothing enabled — text legibility is worth the modest cost
- Use 32-bit colour depth and “display-update” resize method
- Enable bitmap caching
Clipboard works seamlessly between the host browser and the RDP session. Audio works (though I rarely need it). Drive redirection (file transfer via a virtual drive) works if enabled.
What I’d do differently
A couple of small regrets and lessons:
Pin your image versions from the start. I touched on this above, but it bears repeating. Saving a few characters in a YAML file is not worth the debugging session that ensues when an upstream image breaks under you.
Read the upstream changelog before upgrading versions. The Guacamole 1.5→1.6 environment variable rename is documented in the official Docker image docs. I just didn’t read them — assumed latest would be backwards-compatible. It wasn’t, and that’s reasonable: 1.6 is a major version bump.
Stack at a glance
For anyone wanting to replicate this:
- Hypervisor: Hyper-V on Windows Server / Windows 11 Pro
- Docker host: Ubuntu Server VM running Docker and Portainer
- Containers:
guacamole/guacd:1.6.0,guacamole/guacamole:1.6.0,postgres:16-alpine,cloudflare/cloudflared(pinned to a specific version after my earlier lesson learned) - Edge: Cloudflare Tunnel + Cloudflare Access (Free plan)
- Networking: UniFi with VLAN segregation
- DNS-level filtering: AdGuard Home (running independently on my network)
Closing thoughts
The end result is exactly what I wanted: a browser tab with two independent authentication layers protecting it, dropping me into a fully-functional Windows 11 desktop in my home network. No client software to install, no inbound ports on my firewall, and a properly isolated VM so a compromise doesn’t cascade.
The journey took longer than it should have, mostly because of :latest and a Postgres major version bump that I didn’t anticipate. But that’s the value of building things yourself — every painful debug session teaches you something durable about how the pieces fit together.