# Windows 11 Desktop and Google Drive Backups on Your Own Linux Server Version: 2026-10-14 (updated the same day with first-use lessons: sessions that drop, assistants inside Windows, moving off an old machine) Companion article: https://jwatte.com/blog/sovereign-windows-desktop-drive-backups/ Podman version of the Windows setup: https://jwatte.com/downloads/podman-windows-11-dedicated-server-guide.md ## Read before running This is a phased guide, not an unattended installer. Stop when a check fails. It contains no real account credentials, server identifiers or customer data. Replace every `example.com` hostname, `you@example.com` address and `` with your own values. Never paste passwords, OAuth codes, API keys or recovery codes into a chat window, a shared terminal or shell history. Type them into a file with an editor, keep the file readable only by you, and check values by hash rather than by printing them. The reference host was a bare-metal Ubuntu server with dual Intel Xeon Silver 4214R processors, 96 GB of RAM, mirrored NVMe storage, Docker with Compose 2.30 or later, and an existing remotely managed Cloudflare Tunnel. Commands assume a normal Linux user with `sudo` for Docker. Adjust paths and sizes to your machine. ## 1. Preflight checks Run these and keep the output with your notes. ```bash lscpu | grep -E 'Model name|^CPU\(s\)|Virtualization' # expect VT-x or AMD-V ls -l /dev/kvm /dev/net/tun # both must exist systemd-detect-virt # expect: none (bare metal) free -g # budget the guest's RAM as fully used df -h / # leave room for the full virtual disk docker compose version # 2.30+ for env_file format: raw ``` Decide the guest size before you start. The reference VM used 16 cores, 32 GB and a 150 GB thin disk on a 48-thread, 96 GB host. The guest holds all of its memory while running. The disk grows toward its full size as Windows fills it. Open your provider's out-of-band console page now (IPMI/KVM, Serial over LAN) and confirm it works for your server. Economy server lines sometimes omit it. Set a local console password for your Linux user; SSH keys do not work at a KVM console. ## 2. Free space first (inventory, then act) ```bash docker system df # what is reclaimable docker system df -v # which images and build cache entries are in use df -h / ``` Rules that kept the reference cleanup safe: - Write the inventory down before deleting anything. - Prune only unused build cache (`docker builder prune`), and remove only images used by no container, running or stopped. Keep any image your update or rollback records name. - Never remove Docker volumes or stop containers as part of cleanup. - For backups: upload, verify by checksum, then delete the local copy. Keep the newest restore point locally. - Plain deletion is only for regenerable caches. ## 3. Windows 11 with dockur/windows Create a private directory and a Compose file. ```bash mkdir -p ~/win11/storage && chmod 700 ~/win11 ``` `~/win11/compose.yaml`: ```yaml services: windows: # Pin the release you tested by digest: dockurr/windows:@sha256: image: dockurr/windows:@sha256: container_name: win11 environment: VERSION: "11" # Windows 11 Pro, downloaded from Microsoft CPU_CORES: "16" RAM_SIZE: "32G" DISK_SIZE: "150G" ALLOCATE: "N" # thin (sparse) disk USERNAME: "" LANGUAGE: "English" REGION: "en-US" KEYBOARD: "en-US" env_file: - path: ./install.env # first-boot password only; delete after install required: false format: raw # no $ interpolation devices: - /dev/kvm - /dev/net/tun cap_add: - NET_ADMIN ports: - "127.0.0.1:8006:8006" # web viewer (VNC): loopback only, recovery use - "127.0.0.1:3389:3389/tcp" # Remote Desktop: loopback only - "127.0.0.1:3389:3389/udp" volumes: - ./storage:/storage restart: unless-stopped stop_grace_period: 2m ``` Create the password file with an editor (not `echo`), one line, no quotes: ```text PASSWORD= ``` ```bash chmod 600 ~/win11/install.env ~/win11/compose.yaml grep -c $'\r' ~/win11/install.env # expect 0 (no Windows line endings) cd ~/win11 && sudo docker compose config --quiet && echo "compose OK" sudo docker compose up -d ``` Confirm the password reached the container unchanged, without printing it: ```bash A=$(sudo docker exec win11 printenv PASSWORD | tr -d '\n' | sha256sum) B=$(sed -n 's/^PASSWORD=//p' ~/win11/install.env | tr -d '\n' | sha256sum) [ "$A" = "$B" ] && echo "password intact" || echo "MISMATCH: stop and fix the file" ``` Confirm the disk is thin once it exists: ```bash du -sh --apparent-size ~/win11/storage/data.img # nominal size du -sh ~/win11/storage/data.img # real usage ``` Watch the install. Remote Desktop answering inside the container means setup has finished. The VM's internal address is recorded by the container (path observed in release 6.06; check your release): ```bash sudo docker logs --tail 5 win11 sudo docker exec win11 bash -c 'IP=$(cat /run/shm/qemu.ip); timeout 3 bash -c "echo > /dev/tcp/$IP/3389"' && echo "RDP is up" ``` After Windows reaches the desktop: ```bash shred -u ~/win11/install.env # The running container still holds PASSWORD in its environment. Recreate it when nobody is in a session: cd ~/win11 && sudo docker compose up -d --force-recreate ``` dockur signs Windows in automatically. **Never publish port 8006.** Use it only through an SSH tunnel as a recovery console: ```bash ssh -N -L 8006:127.0.0.1:8006 @ # then open http://localhost:8006 ``` Activate Windows with a valid license and set the Windows time zone. Check Microsoft's license terms for virtual machines. ### Shared folder for bulk copies dockur shares one host folder with Windows over Samba. Windows sees it as `\\host.lan\Data`; release 6.06's setup script also mapped it to `Z:` and added a `Shared` shortcut on the desktop. Samba binds only to the VM's internal network. Add the folder before installing, or recreate the container afterwards: ```yaml volumes: - ./storage:/storage - ./shared:/shared # appears in Windows as \\host.lan\Data (Z:) ``` Release 6.06 also uses a `shared` folder inside the storage volume (`./storage/shared`) when `/shared` is not mounted, starting at the next container start. Copy files into the host folder over SSH, then copy them into Windows from `Z:`. A staging folder for large copies usually does not belong in the host's file backup; see 7.4. ## 4. Browser Remote Desktop with Apache Guacamole Guacamole replaces the VNC viewer for daily use. It connects to Windows over RDP and serves the session to a browser. ```bash mkdir -p ~/guacamole && chmod 700 ~/guacamole && cd ~/guacamole ``` `~/guacamole/compose.yaml`: ```yaml services: guacd: image: guacamole/guacd:1.6.0@sha256: restart: unless-stopped networks: [guac, win11] # joins the Windows container's network db: image: postgres:16-alpine@sha256: restart: unless-stopped env_file: [./db.env] volumes: - ./db:/var/lib/postgresql/data - ./init:/docker-entrypoint-initdb.d:ro networks: [guac] guacamole: image: guacamole/guacamole:1.6.0@sha256: restart: unless-stopped depends_on: [guacd, db] env_file: [./guac.env] environment: GUACD_HOSTNAME: guacd POSTGRESQL_HOSTNAME: db POSTGRESQL_DATABASE: guacamole POSTGRESQL_USERNAME: guacamole POSTGRESQL_AUTO_CREATE_ACCOUNTS: "true" HEADER_ENABLED: "true" # see the trust note below HTTP_AUTH_HEADER: Cf-Access-Authenticated-User-Email WEBAPP_CONTEXT: ROOT ports: - "127.0.0.1:8007:8080" # loopback only networks: [guac] networks: guac: {} win11: external: true name: win11_default # check with: docker network ls ``` Generate database credentials without printing them, and the stock schema: ```bash cd ~/guacamole umask 077 pw=$(openssl rand -hex 24) printf 'POSTGRES_DB=guacamole\nPOSTGRES_USER=guacamole\nPOSTGRES_PASSWORD=%s\n' "$pw" > db.env printf 'POSTGRESQL_PASSWORD=%s\n' "$pw" > guac.env unset pw; umask 022 mkdir -p init db sudo docker run --rm guacamole/guacamole:1.6.0@sha256: /opt/guacamole/bin/initdb.sh --postgresql > init/001-initdb.sql ``` `init/002-setup.sql` (named users, no default admin, no stored Windows password): ```sql INSERT INTO guacamole_entity (name, type) VALUES ('you@example.com', 'USER'); INSERT INTO guacamole_user (entity_id, password_hash, password_salt, password_date) SELECT entity_id, decode(md5(random()::text) || md5(random()::text), 'hex'), decode(md5(random()::text) || md5(random()::text), 'hex'), now() FROM guacamole_entity WHERE type = 'USER' AND name = 'you@example.com'; INSERT INTO guacamole_system_permission (entity_id, permission) SELECT entity_id, p.permission::guacamole_system_permission_type FROM guacamole_entity, (VALUES ('ADMINISTER'), ('CREATE_CONNECTION'), ('CREATE_USER')) AS p(permission) WHERE type = 'USER' AND name = 'you@example.com'; DELETE FROM guacamole_entity WHERE type = 'USER' AND name = 'guacadmin'; INSERT INTO guacamole_connection (connection_name, protocol, max_connections, max_connections_per_user) VALUES ('Windows 11', 'rdp', 2, 2); INSERT INTO guacamole_connection_parameter (connection_id, parameter_name, parameter_value) SELECT c.connection_id, v.name, v.value FROM guacamole_connection c, (VALUES ('hostname', 'win11'), ('port', '3389'), ('username', ''), ('security', 'any'), ('ignore-cert', 'true'), ('resize-method', 'display-update'), ('color-depth', '24'), ('enable-wallpaper', 'false'), ('enable-theming', 'true'), ('enable-font-smoothing', 'true'), ('enable-full-window-drag', 'false'), ('enable-desktop-composition', 'false'), ('enable-menu-animations', 'false'), ('server-layout', 'en-us-qwerty'), ('timezone', 'America/Denver')) AS v(name, value) WHERE c.connection_name = 'Windows 11'; INSERT INTO guacamole_connection_permission (entity_id, connection_id, permission) SELECT e.entity_id, c.connection_id, 'READ'::guacamole_object_permission_type FROM guacamole_entity e, guacamole_connection c WHERE e.type = 'USER' AND e.name = 'you@example.com' AND c.connection_name = 'Windows 11'; ``` The explicit `::guacamole_object_permission_type` cast matters: PostgreSQL does not convert a text literal to that enum inside `INSERT ... SELECT`. ```bash chmod 600 db.env guac.env compose.yaml chmod 755 init && chmod 644 init/*.sql # the postgres user must read the init scripts sudo docker compose up -d sudo docker compose logs db | grep -E '002-setup|ERROR' sudo docker run --rm --network win11_default busybox nc -z -w 3 win11 3389 && echo "RDP reachable from Guacamole's network" ``` Test sign-in locally. The token API needs a form-encoded body: ```bash T() { curl -s -X POST -H 'Content-Type: application/x-www-form-urlencoded' --data '' "$@" http://127.0.0.1:8007/api/tokens | head -c 160; echo; } T -H 'Cf-Access-Authenticated-User-Email: you@example.com' # expect a token for you T # expect "Invalid login." ``` **Trust note.** Header authentication trusts whatever email the header carries. It is acceptable only when all of these hold: Guacamole listens on loopback only, cloudflared enforces the Access token (Protect with Access, next section), and every person Access admits should have the same rights. With `POSTGRESQL_AUTO_CREATE_ACCOUNTS`, an unknown address gets an empty account with no connections; delete any you see. For several users with different rights, use Guacamole's OpenID Connect support instead. ### 4.1 Keep sessions usable **Expect drops, and make them cheap.** Guacamole's web application waits 15 seconds for data from guacd and then closes the connection. The limit is a constant in its code (`InetGuacamoleSocket`), not a setting. A hidden browser tab, a sleeping laptop or phone, or a short network stall can all trigger it. Chrome runs a hidden page's timers once per second, and after five minutes hidden and 30 seconds silent as rarely as once per minute. Keep the desktop in its own visible browser window. **Lengthen Guacamole's sign-in session.** Tokens expire after 60 minutes of inactivity by default (`api-session-timeout`). The image reads any property from an environment variable (uppercase, hyphens become underscores). Add this to the `guacamole` service, then recreate only that container: ```yaml API_SESSION_TIMEOUT: "300" # minutes ``` ```bash cd ~/guacamole && sudo docker compose up -d guacamole ``` **Keep the Windows session alive between connections.** A dropped Remote Desktop connection leaves the Windows session running unless a session time limit ends it. In an administrator PowerShell inside Windows: ```powershell powercfg /change monitor-timeout-ac 0 # the display never turns off (a display-off also triggers the inactivity lock) powercfg /change standby-timeout-ac 0 # never sleep powercfg /change hibernate-timeout-ac 0 # Lock after 4 hours without input (Interactive logon: Machine inactivity limit; 0 to 599,940 seconds) New-ItemProperty -Path 'HKLM:\SOFTWARE\Microsoft\Windows\CurrentVersion\Policies\System' ` -Name InactivityTimeoutSecs -PropertyType DWord -Value 14400 -Force # Say "never" explicitly for Remote Desktop idle and disconnected-session limits (Session Time Limits policies) $ts = 'HKLM:\SOFTWARE\Policies\Microsoft\Windows NT\Terminal Services' if (-not (Test-Path $ts)) { New-Item -Path $ts | Out-Null } # create the key only if it is missing New-ItemProperty -Path $ts -Name MaxIdleTime -PropertyType DWord -Value 0 -Force New-ItemProperty -Path $ts -Name MaxDisconnectionTime -PropertyType DWord -Value 0 -Force Restart-Computer # the inactivity limit takes effect after a restart ``` **Decide whether a reconnect asks for the Windows password.** With the password blank, every drop ends at a prompt. Typing it into the connection (Settings, Connections, the Windows connection, Authentication) makes reconnects seamless. Anyone who passes Access then gets the desktop without the Windows password, and the value is stored in Guacamole's database. That is a reasonable trade only when Access admits a single owner. ## 5. Cloudflare Access and Tunnel Do these in order. 1. **Access application first.** Zero Trust, Access controls, Applications: add `win11.example.com` to a self-hosted application whose policy allows only your email addresses. One application can cover several hostnames with one reusable policy. Note the application's AUD tag and your team name. 2. **Tunnel route second.** Networks, Tunnels, your tunnel, Routes, Add route, Published application: subdomain `win11`, your domain, service `http://127.0.0.1:8007`. Under Additional application settings, Access: turn on **Protect with Access**, enter the team name and the application's AUD tag. Save; the dashboard creates the DNS record. 3. **Verify from outside the server:** ```bash for p in / /websocket-tunnel /api/tokens; do curl -s -o /dev/null -w "$p %{http_code} %{redirect_url}\n" "https://win11.example.com$p" | cut -c1-110 done # Every line should be a 302 to https://.cloudflareaccess.com/cdn-cgi/access/login/... ``` A path that answers 200 without signing in is an open door. Fix it before using the desktop. ## 6. Out-of-band access and a private link directory Collect these in one private page behind the same Access login, so they are findable when the server misbehaves: - the Windows desktop (`https://win11.example.com/`) and its recovery console (`http://localhost:8006` through SSH), - the provider's IPMI/KVM page for this server (browser KVM, Java fallback, Serial over LAN, IPMI reset), - the provider's support-ticket page, - the server's renewal date and commitment terms. ## 7. Backups to existing Google Drive storage ### 7.1 Design - **restic** encrypts and deduplicates on the server. **rclone** gives restic a Google Drive backend. - **History lives on Drive. Only the newest restore point stays local.** - **A local prune must never reach Drive.** Do not mirror a local repository to Drive with `rclone sync`; pruning the local copy would delete the Drive history. Use two repositories and `restic copy` between them. - **The restic password lives in your password manager as well as the server.** Without it, the Drive copy cannot be restored. ### 7.2 rclone remote with the narrowest scope 1. In Google Cloud Console, create your own OAuth client (Desktop app) and enable the Google Drive API. rclone's shared client ID is being retired during 2026. 2. Run `rclone config`, create a `drive` remote named `gdrive`, enter your client ID and secret, and choose scope `drive.file` (rclone sees only files it created). 3. On a headless server, complete the browser step with `rclone authorize` on a machine with a browser, or from a desktop session on the server. ```bash rclone about gdrive: # quota check: total, used, trashed, free rclone mkdir "gdrive:backups" ``` ### 7.3 Repositories ```bash export RESTIC_PASSWORD_FILE=~/.config/restic/password # chmod 600; copy into your password manager export RCLONE_CONFIG=~/.config/rclone/rclone.conf # name the config file explicitly on every run export RCLONE_DRIVE_CHUNK_SIZE=128M # measured setting; see 7.6 for its memory cost LOCAL=/srv/restic-local DRIVE="rclone:gdrive:backups/$(hostname -s)" restic -r "$LOCAL" init restic -r "$DRIVE" init --from-repo "$LOCAL" --from-password-file "$RESTIC_PASSWORD_FILE" --copy-chunker-params ``` `--copy-chunker-params` keeps deduplication working between the two repositories. Test a larger pack size on a small repository before using it at scale; fewer, larger files suit Drive (`--pack-size 64` in MiB, restic 0.14 or later). Larger packs need more temporary disk space while uploading. ### 7.4 Daily run ```bash #!/usr/bin/env bash set -euo pipefail export RESTIC_PASSWORD_FILE=~/.config/restic/password RCLONE_CONFIG=~/.config/rclone/rclone.conf RCLONE_DRIVE_CHUNK_SIZE=128M LOCAL=/srv/restic-local DRIVE="rclone:gdrive:backups/$(hostname -s)" restic -r "$LOCAL" backup --pack-size 64 --exclude-file ~/.config/restic/excludes.txt ~ restic -r "$DRIVE" copy --from-repo "$LOCAL" --from-password-file "$RESTIC_PASSWORD_FILE" restic -r "$DRIVE" forget --keep-daily 7 --keep-weekly 5 --keep-monthly 12 --prune restic -r "$LOCAL" forget --keep-last 1 --prune restic -r "$DRIVE" check ``` Run it from a systemd user timer (enable lingering so it runs without a login), and have it report success and failure somewhere you will see. Exclude what is rebuilt, re-downloaded or backed up separately: Docker data, package caches, `node_modules`, the Windows VM's disk image (back up the files that matter from inside Windows instead), and live database files (dump them, below). **Check the real exclude file after adding the VM.** In the reference rollout the plan said to exclude the disk image, but the server's existing exclude file did not, and a 150 GB live image would have entered that night's backup. Exclude the whole storage folder, which also covers the `./storage/shared` staging folder: ```bash grep -n 'win11' ~/.config/restic/excludes.txt || echo "$HOME/win11/storage" >> ~/.config/restic/excludes.txt ``` ### 7.5 Databases and plain archives ```bash STAMP=$(date -u +%Y%m%dT%H%M%SZ) docker compose -f ~/guacamole/compose.yaml exec -T db pg_dump -U guacamole guacamole | gzip > ~/dumps/guacamole-$STAMP.sql.gz rclone copy ~/dumps/ "gdrive:backups/dumps/" --include "guacamole-$STAMP.sql.gz" rclone check ~/dumps/ "gdrive:backups/dumps/" --include "guacamole-$STAMP.sql.gz" --one-way # keep only the newest dump locally ls -1t ~/dumps/guacamole-*.sql.gz | tail -n +2 | xargs -r rm -- ``` Encrypt sensitive plain archives before upload (an rclone `crypt` remote, or `age`/`gpg`), or keep them inside restic. ### 7.6 Limits and verification - **Your own OAuth client is the biggest speed setting.** In the October 13, 2026 field observation, a Drive copy ran under 1 MiB/s on rclone's shared client and at a sampled rate near 27 MiB/s on an own client; a 19.5 GiB first copy finished in about 11 minutes. Those are rounded single observations, not a benchmark. Details: [Sovereign AI Made Simple](https://jwatte.com/blog/sovereign-ai-made-simple/) and the [operator kit](https://jwatte.com/downloads/sovereign-ai-workstation-ops-kit.md). - **Do not cap a healthy copy at a fixed low request rate** (for example one request per second). Let rclone's adaptive backoff handle quota errors, and reduce concurrency when errors appear. - **Raise concurrency only after measuring.** A dry run, then `rclone copy ... --transfers 8 --drive-chunk-size 128M`, recording completed bytes, retries, elapsed time and a real restore. Eight 128 MiB chunks need roughly 1 GiB of buffers alone. - **Pass the rclone configuration explicitly** (`--config`, or `RCLONE_CONFIG` as above). rclone saves its config by writing a replacement file, which can replace a symlink instead of updating the file it points to. - Google documents an upload and copy limit of **750 GB per user per day** across My Drive and shared drives. Plan first uploads larger than that over several days. - Deleted files go to Drive's trash and still count against storage until it is emptied. - After uploads: `rclone check` for archives; `restic check --read-data-subset=5%` on the Drive repository weekly. - Monthly: restore one representative directory and one database dump to a scratch location and open them. ## 8. Dependencies at a glance | Dependency | Needed for | Notes | |---|---|---| | KVM (`kvm_intel` or `kvm_amd`, `/dev/kvm`) and `/dev/net/tun` | The Windows VM | Bare metal, or a host that supports nested virtualization | | Docker Engine and Compose 2.30+ | Every container here | `env_file` with `format: raw` needs 2.30 | | `dockurr/windows` image | Windows 11 | Pin by digest; it downloads the ISO from Microsoft | | Windows 11 license | Activation | Check Microsoft's terms for virtual machines | | `guacamole/guacd`, `guacamole/guacamole` 1.6, PostgreSQL | Browser Remote Desktop | Java runs inside the Guacamole image, not on the host | | `cloudflared`, a Cloudflare zone, a Zero Trust organization | The private hostname | The Zero Trust free plan covers teams under 50 users | | Provider IPMI/KVM | Recovery when the OS or network is down | Check that your server line includes it | | rclone with your own Google OAuth client | Drive transport | `drive.file` scope; explicit config path | | restic 0.14 or later | Encrypted backups | `copy --from-repo`, `--copy-chunker-params`, `--pack-size` | | Google One or Workspace storage | Backup target | 750 GB per user per day upload and copy limit | | `openssl`, `sha256sum`, `shred`, `curl`, `busybox nc` | Secrets and checks | Generate, hash, delete and probe without printing values | | systemd user services with lingering | Timers | Backups and checks run without a login session | | Node.js (for `npx`) | Optional tools such as Desktop Commander | Pin versions you have tested | | A password manager | Windows password, restic password, OAuth client secret | The only copy must never be on the server alone | ## 9. More uses for Guacamole, QEMU and VNC **Guacamole is a gateway for more than one Windows machine.** The same login can list several connections: - **SSH** to Linux servers (`hostname`, `port`, `username`, `private-key`; leave secrets blank to be prompted). - **VNC** to other desktops (`hostname`, `port`, `password`), with `read-only` for a view-only screen. - **More RDP machines**, grouped and granted per user. Optional features, off by default here, each with a cost: - **File transfer:** `enable-drive`, `drive-path`, `create-drive-path` give the Windows session a folder on the server. Keep that folder outside anything sensitive. - **Printing:** `enable-printing` sends print jobs to a PDF you can download. - **Clipboard controls:** `disable-copy` and `disable-paste` stop text leaving or entering a session. - **Session recording:** `recording-path`, `create-recording-path` and `recording-name` record sessions to disk for later playback with Guacamole's recording player. Recordings capture everything on screen, so store and expire them like any other sensitive file. - **A second factor:** `TOTP_ENABLED: "true"` adds authenticator-app codes on top of another sign-in method; the database extension stores each user's key. With Cloudflare Access already in front, it matters most if you also allow Guacamole's own password login. - **Several people with different rights:** use Guacamole's OpenID Connect extension instead of header sign-in. **The QEMU monitor is a headless control panel.** dockur exposes it inside the container (`/run/shm/monitor.sock` in release 6.06). Through it you can: - `screendump /tmp/screen.png -f png` to see the screen during setup without opening a viewer; - `system_powerdown` to ask Windows for a clean shutdown; - `sendkey ctrl-alt-delete` when the guest needs that key combination; - `info status` to confirm the VM is running. Internal snapshots (`savevm`, `loadvm`) need a qcow2 disk; set `DISK_FMT: "qcow2"` before installing if you want them. With the default raw disk, a rollback point is a copy of the stopped disk image (`cp --sparse=always data.img data.img.before-change`), which keeps the copy thin. **VNC stays useful as the recovery console.** Keep the container's web viewer on loopback and reach it through an SSH tunnel. It shows the boot screen and Windows updates before Remote Desktop is available. **Other guests.** The same maintainers publish `qemus/qemu`, which boots other operating system ISOs the same way. Check each operating system's license for virtualization before relying on it. ## 10. Assistants inside Windows **Remote Desktop Commander.** It lets an AI client run terminal and file tools on this Windows account. Tool calls travel through Desktop Commander's relay service and run with the account's permissions. In a normal (not administrator) PowerShell, after installing a pinned Node.js LTS: ```powershell $dc = "$env:LOCALAPPDATA\DesktopCommanderRemote" npm.cmd install --prefix $dc --no-fund --no-audit "@wonderwhy-er/desktop-commander@" node "$dc\node_modules\@wonderwhy-er\desktop-commander\dist\index.js" remote # A browser opens: check the code matches, sign in, authorize. When it shows connected, press Ctrl+C. ``` Start it hidden at every sign-in, with a loop that restarts it if it exits. Save as `$dc\run-remote.cmd`: ```bat @echo off set "DC=%~dp0" :loop >>"%DC%remote.log" echo %date% %time% start "C:\Program Files\nodejs\node.exe" "%DC%node_modules\@wonderwhy-er\desktop-commander\dist\index.js" remote >>"%DC%remote.log" 2>&1 >>"%DC%remote.log" echo %date% %time% exit code %errorlevel% ping -n 16 127.0.0.1 >nul goto loop ``` Put the redirection first on the `echo` lines. Written as `echo ... %errorlevel%>>file`, an exit code such as 3 becomes the redirect `3>>` and the line never reaches the log. ```powershell $account = "$env:COMPUTERNAME\$env:USERNAME" $action = New-ScheduledTaskAction -Execute "$env:WINDIR\System32\conhost.exe" ` -Argument "--headless cmd.exe /d /c `"$dc\run-remote.cmd`"" -WorkingDirectory $dc Register-ScheduledTask -TaskName 'Desktop Commander Remote' -Action $action ` -Trigger (New-ScheduledTaskTrigger -AtLogOn -User $account) ` -Principal (New-ScheduledTaskPrincipal -UserId $account -LogonType Interactive -RunLevel Limited) ` -Settings (New-ScheduledTaskSettingsSet -ExecutionTimeLimit ([TimeSpan]::Zero) -MultipleInstances IgnoreNew) Start-ScheduledTask -TaskName 'Desktop Commander Remote' ``` `conhost.exe --headless` keeps the console window hidden. The option is undocumented, so after the next sign-in confirm in Task Manager that `node.exe` is running. An interactive task runs in your desktop session, so programs the assistant opens appear on your screen. **OpenSSH Server for automation from the server.** In an administrator PowerShell: ```powershell Add-WindowsCapability -Online -Name OpenSSH.Server~~~~0.0.1.0 Set-Service sshd -StartupType Automatic; Start-Service sshd # first start creates host keys and sshd_config $keys = "$env:ProgramData\ssh\administrators_authorized_keys" # administrators use this file, not ~\.ssh Add-Content -Path $keys -Value '' icacls $keys /inheritance:r /grant '*S-1-5-32-544:F' /grant '*S-1-5-18:F' # Edit $env:ProgramData\ssh\sshd_config: put "PasswordAuthentication no" ABOVE the "Match Group administrators" block Set-NetFirewallRule -Name OpenSSH-Server-In-TCP -RemoteAddress 172.16.0.0/12 # the server's private Docker networks only Restart-Service sshd ``` dockur forwards the container's ports, except its own reserved ones, to Windows. The server can therefore reach Windows' SSH at the container's address (`docker inspect -f '{{range .NetworkSettings.Networks}}{{.IPAddress}}{{end}}' win11`) with no port published. Settings placed after a `Match` line apply only inside that block, which is why the order in `sshd_config` matters. ## 11. Moving in from an old machine **Inventory first.** List what lives only on the old machine: working folders, assistant memory, scripts, browser profiles, scheduled jobs and the accounts each tool is signed in to. **One archive per set, hashed at both ends.** From the old machine (Git Bash works on Windows): ```bash SET=downloads tar -cf - -C ~ --exclude=node_modules --exclude=__pycache__ Downloads \ | tee >(sha256sum > "$SET.local.sha256") \ | ssh @ "cat > ~/win11/storage/shared/from-old/$SET.tar.part" # give the local hash a moment to finish writing, then compare: cat "$SET.local.sha256" ssh @ "sha256sum ~/win11/storage/shared/from-old/$SET.tar.part" ``` Rename `.part` to `.tar` only when the hashes match. A `tar` exit status of 1 means a file changed while it was read; the archive is still valid, but look at which file. Windows' built-in `tar` unpacks the archive from `Z:`. **Leave sign-ins behind.** Exclude command-line tool tokens, SSH private keys, API-key files and password notes. Sign in again on the new machine, then revoke the old machine's sessions. **Skip what can be rebuilt, but check before skipping.** Dependency folders and generated pages dominate transfer time: one build folder of about 48,000 small files moved at under half a megabyte per second. Before skipping a repository because "it's on GitHub": ```bash git -C fetch origin git -C status --short # uncommitted changes git -C stash list git -C log --branches --not --remotes --oneline # commits on no remote branch git -C cherry -v origin/main # "+" = not upstream, even under another commit ID git -C bundle create ../-unpushed.bundle ^origin/main # keep them ``` **A final copy on Drive, then retire.** Upload the verified archives and check them against Drive's checksums: ```bash rclone copy ~/win11/storage/shared/from-old "gdrive:backups/old-machine-final" \ --include '/*.tar' --include '/*.sha256' --checksum rclone check ~/win11/storage/shared/from-old "gdrive:backups/old-machine-final" \ --include '/*.tar' --include '/*.sha256' --one-way # expect: 0 differences ``` Keep the old machine until a restore test from Drive has worked and every scheduled job it ran has a new home. ## 12. Other reusable pieces from the same rollout These were deployed in the same 36-hour window and generalize well: - **A private link directory.** A small page behind the same Access login lists every service, the provider KVM and support pages, and what each one is for. Give it a second mode with `localhost` addresses for when you connect through SSH forwarding. - **One tunnel, many hostnames, one Access application.** Add a hostname to the application first, then the route with Protect with Access. Editing a route's service address takes effect without restarting the service behind it. - **Serverless business apps back up the same way.** For an application on Workers and D1 (or similar), keep production configuration only on the owner's deploy machine. A token-protected export endpoint, pulled by a server timer, can then send daily and monthly copies to Drive. - **Deploy helpers that cannot hide a failure.** A helper runs the full test suite, applies the database migration, verifies the schema (retrying transient read errors), deploys the backend before the frontend and checks live routes. Record the real exit code, and read it. - **A private GitHub mirror with clean history.** Scan every change for secrets (for example with gitleaks) before pushing. If real customer data ever entered a repository's history, recreate the repository from a clean snapshot rather than trying to rewrite history. - **IndexNow for static sites.** Publish a key file at the site root plus a key meta tag, then submit every sitemap URL after a deploy. Search engines that use IndexNow can then pick up corrected pages without waiting for a crawl. Bing may need a short verification delay the first time. - **Headless agent jobs with a written contract.** Each job gets a brief, a result file and a single exit line in a progress log. The watcher waits for every terminal state, not only success. ## 13. Final checklist - [ ] `/dev/kvm` present, guest size budgeted, provider KVM console tested, local console password set. - [ ] Windows image pinned by digest; ports on `127.0.0.1` only; disk confirmed thin. - [ ] Password file verified by hash, then shredded; container recreated afterwards. - [ ] Guacamole on loopback; `guacadmin` deleted; only named users granted the connection; no stored Windows password. - [ ] Access application covers the hostname; tunnel route has Protect with Access; page, WebSocket and API paths all redirect to sign-in from outside. - [ ] Link directory includes the desktop, recovery console, provider KVM and support pages. - [ ] rclone uses your own OAuth client and `drive.file` scope. - [ ] Drive repository holds the history; local repository keeps one snapshot; no `rclone sync` over Drive history. - [ ] restic password stored in a password manager. - [ ] Weekly data check and monthly restore test scheduled and reporting. - [ ] The host's real backup exclude file covers the VM storage folder. - [ ] Guacamole session timeout raised; Windows has no sleep or display-off timer, a deliberate inactivity lock, and no session time limits; stored-password choice made on purpose. - [ ] Desktop Commander and Node.js pinned; the sign-in task restarts it; SSH into Windows is key-only and limited to the server's private network. - [ ] Old machine: unpushed git work checked and bundled; sign-ins re-created, not copied; archives hash-matched and `rclone check` clean before retirement. ## Sources - dockur/windows: https://github.com/dockur/windows - Docker Compose environment variables and `env_file` formats: https://docs.docker.com/compose/how-tos/environment-variables/set-environment-variables/ - Apache Guacamole Docker installation: https://guacamole.apache.org/doc/gug/guacamole-docker.html - Apache Guacamole HTTP header authentication: https://guacamole.apache.org/doc/gug/header-auth.html - Cloudflare self-hosted applications and origin token validation: https://developers.cloudflare.com/cloudflare-one/applications/configure-apps/self-hosted-apps/ - OVHcloud IPMI console guide: https://docs.ovhcloud.com/en/guides/bare-metal-cloud/dedicated-servers/ipmi - rclone Google Drive backend (scopes, client ID): https://rclone.org/drive/ - Google Drive API usage limits: https://developers.google.com/drive/api/guides/limits - restic working with repositories (copy, chunker parameters): https://restic.readthedocs.io/en/stable/045_working_with_repos.html - Apache Guacamole connection parameters (SSH, VNC, RDP drive, printing, recording): https://guacamole.apache.org/doc/gug/configuring-guacamole.html - Apache Guacamole TOTP, OpenID Connect and recording playback: https://guacamole.apache.org/doc/gug/totp-auth.html, https://guacamole.apache.org/doc/gug/openid-auth.html, https://guacamole.apache.org/doc/gug/recording-playback.html - QEMU monitor and disk image formats: https://www.qemu.org/docs/master/system/monitor.html, https://www.qemu.org/docs/master/system/images.html - qemus/qemu: https://github.com/qemus/qemu - IndexNow: https://www.indexnow.org/documentation - gitleaks: https://github.com/gitleaks/gitleaks - Measured Drive copy observations: https://jwatte.com/blog/sovereign-ai-made-simple/ and https://jwatte.com/downloads/sovereign-ai-workstation-ops-kit.md - restic tuning parameters (pack size): https://restic.readthedocs.io/en/stable/047_tuning_parameters.html - Guacamole session timeout and environment properties: https://guacamole.apache.org/doc/gug/configuring-guacamole.html - Guacamole's 15-second read timeout (`SOCKET_TIMEOUT`): https://github.com/apache/guacamole-client/blob/main/guacamole-common/src/main/java/org/apache/guacamole/net/InetGuacamoleSocket.java - Chrome timer throttling for hidden pages: https://developer.chrome.com/blog/timer-throttling-in-chrome-88 - Interactive logon: Machine inactivity limit: https://learn.microsoft.com/en-us/previous-versions/windows/it-pro/windows-10/security/threat-protection/security-policy-settings/interactive-logon-machine-inactivity-limit - Remote Desktop session locks and time limits: https://learn.microsoft.com/en-us/troubleshoot/windows-server/remote/troubleshoot-unexpected-rds-session-locks-or-disconnections - OpenSSH for Windows (install, administrator keys): https://learn.microsoft.com/en-us/windows-server/administration/openssh/openssh_install_firstuse and https://learn.microsoft.com/en-us/windows-server/administration/openssh/openssh_keymanagement - Desktop Commander Remote Device: https://github.com/wonderwhy-er/DesktopCommanderMCP/blob/main/src/remote-device/README.md - dockur shared folder and port forwarding: read from the release 6.06 image's `samba.sh`, `network.sh` and Windows setup script