# Windows 11 on a Dedicated Linux Server with Podman and Quadlet Version: 2026-10-14 Companion article: https://jwatte.com/blog/podman-vs-docker-windows-11-dedicated-server/ The Docker version of the same desktop (Guacamole, Cloudflare Access, Drive backups): https://jwatte.com/downloads/sovereign-windows-desktop-and-drive-backup-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 `you@example.com`, `` and path with your own values. Never paste passwords, API keys or recovery codes into a chat window, a shared terminal or shell history. The steps below read the Windows password with `read -rs`, which neither echoes it nor stores it in history, and check it by hash rather than by printing it. ### What was tested, and what was not These were checked on October 14, 2026, on a bare-metal Ubuntu 26.04 server (dual Intel Xeon Silver, 96 GB RAM, NVMe), with each Podman version running inside a disposable container: | Check | Podman 5.7.0 (Ubuntu 26.04 `apt`) | Podman 5.8.7 (quay.io/podman/stable) | Podman 6.2 development build | |---|---|---|---| | `dockurr/windows:6.06` started as root, reached the Windows download | Yes | Yes | Yes (Microsoft refused the request by IP; the container fell back to a static link) | | The six Quadlet files in this guide generated services with no errors | Yes | Yes | Yes | | Password delivered by `podman secret` as an environment variable | Yes | Yes | Not run | | Windows as a rootless container | Not run | Failed at the container's first `/proc` check (likely an artifact of nesting) | Not run | **Not tested:** a complete Windows install under Podman, rootless Windows on a bare host, the Guacamole pod running end to end, and moving an existing Docker-installed Windows disk to Podman. Sections that depend on those say so. The recommended path is **rootful** Podman: units in `/etc/containers/systemd/`, commands with `sudo`. A rootless variant is in section 10. ## 1. Preflight checks ```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) sudo apt install -y cpu-checker && sudo kvm-ok # expect: KVM acceleration can be used stat -fc %T /sys/fs/cgroup # expect: cgroup2fs (Podman 6 requires cgroups v2) free -g # the guest holds all of its RAM while running df -h / # leave room for the full virtual disk sudo ss -ltnp | grep -E ':(8006|3389|8007)\b' # expect nothing: these ports must be free ``` If the last command shows a listener, something (often an existing Docker container) already owns that port. Podman and Docker can live on one host, but two engines cannot publish the same host port, and they never share containers or image stores. Size the guest before you start. dockur's defaults are 2 cores, 4 GB of RAM and a 64 GB disk. The units below use 8 cores, 16 GB and 128 GB. The disk is thin by default (`ALLOCATE=N`), so it grows as Windows fills it. ## 2. Install Podman Ubuntu 26.04: ```bash sudo apt update && sudo apt install -y podman podman --version # the archive shipped 5.7.0 on 2026-10-14 sudo podman info --format 'network={{.Host.NetworkBackend}} cgroups={{.Host.CgroupsVersion}} runtime={{.Host.OCIRuntime.Name}}' ls /usr/libexec/podman/quadlet # the Quadlet generator ``` Expect `network=netavark cgroups=v2 runtime=crun`. On 2026-10-14, `apt install podman` brought in crun 1.21, netavark 1.16.1, aardvark-dns 1.16.0, conmon 2.1.13, buildah 1.42.1 and passt (pasta). **Use fully qualified image names everywhere** (`docker.io/dockurr/windows`, not `dockurr/windows`). With several registries configured, Podman treats a short name as ambiguous: it prompts in a terminal and fails in a script or a systemd unit. **Root and rootless are separate stores.** `sudo podman images` shows the system store (`/var/lib/containers/storage`); `podman images` shows yours (`~/.local/share/containers/storage`). Pull an image in the store that will run it. ## 3. Ten commands to know ```bash sudo podman run --rm docker.io/library/alpine:3.22 echo hello # run once and remove sudo podman ps -a # all containers sudo podman logs -f win11 # follow a container's log sudo podman exec -it win11 bash # a shell inside a running container sudo podman images && sudo podman system df # images and disk use sudo podman build -t localhost/myapp:1 . # Containerfile or Dockerfile sudo podman secret ls # secrets (names only) sudo podman pod ps && sudo podman ps --pod # pods and their members sudo podman kube generate guac > guac-pod.yaml # export a pod as Kubernetes YAML sudo podman system prune # remove stopped containers, unused pods and networks, dangling images ``` `podman system prune` leaves volumes alone unless you add `--volumes`. On Podman 6, `podman volume prune` removes only unused anonymous volumes unless you add `--all`. Read what a prune will remove before running it on a server you care about. ## 4. Storage and the first-boot password ```bash sudo mkdir -p /srv/win11/storage && sudo chmod 700 /srv/win11 read -rs -p 'Windows password: ' PW; echo printf '%s' "$PW" | sudo podman secret create win11-password - unset PW sudo podman secret ls # shows the name and driver, never the value ``` What the default protects: the `file` driver keeps the secret in a read-protected file in Podman's storage. It is not encrypted; the `pass` driver stores it GPG-encrypted if you need that. You will delete the secret once Windows is installed (section 5.4). **Avoid repeated Microsoft downloads.** After several automated Windows download requests from one address in a day, Microsoft refused ours ("Microsoft blocked the automated download request based on your IP address"). dockur then fell back to a static link. If you expect to reinstall or test more than once, download the ISO once and give it to the container: either `Environment=VERSION=https://` or `Volume=/srv/win11/win11.iso:/custom.iso:ro` (when `/custom.iso` is bound, `VERSION` is ignored). ## 5. Windows as a Quadlet service ### 5.1 Pin the image ```bash sudo podman pull docker.io/dockurr/windows:6.06 sudo podman image inspect --format '{{index .RepoDigests 0}}' docker.io/dockurr/windows:6.06 ``` Put that `docker.io/dockurr/windows@sha256:` value in `Image=` below and keep the tag in a comment. A tag can move; a digest cannot. Pulling first also keeps the first service start from spending its time limit on a download of about 1 GB. ### 5.2 The unit files `/etc/containers/systemd/win11.network`: ```ini # A named network gives containers DNS by name (the default "podman" network has none). [Network] NetworkName=win11 ``` `/etc/containers/systemd/win11.container`: ```ini [Unit] Description=Windows 11 VM (dockurr/windows) under Podman Wants=network-online.target After=network-online.target [Container] ContainerName=win11 # dockurr/windows 6.06, pinned by digest Image=docker.io/dockurr/windows@sha256: Network=win11.network Environment=VERSION=11 Environment=CPU_CORES=8 Environment=RAM_SIZE=16G Environment=DISK_SIZE=128G Environment=USERNAME= # First-boot password from the Podman secret, delivered as an environment variable. # Remove this line and the secret once Windows has finished installing (5.4). Secret=win11-password,type=env,target=PASSWORD AddDevice=/dev/kvm AddDevice=/dev/net/tun AddCapability=NET_ADMIN # Loopback only: reach these through an SSH tunnel or a reverse proxy, never directly from the internet. PublishPort=127.0.0.1:8006:8006 PublishPort=127.0.0.1:3389:3389/tcp PublishPort=127.0.0.1:3389:3389/udp Volume=/srv/win11/storage:/storage StopTimeout=120 [Service] Restart=on-failure TimeoutStartSec=900 TimeoutStopSec=180 [Install] WantedBy=multi-user.target ``` On a host with SELinux enforcing (Fedora, RHEL), append `:Z` to the `Volume=` line so the container may write to the folder. Ubuntu uses AppArmor and needs no suffix. `StopTimeout=120` gives Windows two minutes to shut down when the service stops; `TimeoutStopSec=180` keeps systemd from killing it sooner. ### 5.3 Check, start and watch ```bash sudo /usr/libexec/podman/quadlet -dryrun 2>&1 | grep -i -E 'error|unsupported' || echo "Quadlet files OK" sudo systemctl daemon-reload sudo systemctl start win11.service systemctl status win11.service --no-pager sudo podman logs -f win11 # Ctrl+C stops following, not the VM ``` Quadlet services are generated, so there is no `systemctl enable` step: the `[Install]` section is what starts the service at boot. After every edit to a unit file, run `sudo systemctl daemon-reload`. The first log lines should read `Starting Windows for Podman`, then `Requesting Windows 11 from the Microsoft servers...` and `Downloading Windows 11...`. Watch the install through the web viewer over an SSH tunnel: ```bash ssh -N -L 8006:127.0.0.1:8006 @ # then open http://localhost:8006 on your own machine ``` Check that the password reached the container intact, without printing it: ```bash A=$(sudo podman exec win11 printenv PASSWORD | tr -d '\n' | sha256sum) read -rs -p 'Type the Windows password again: ' PW; echo B=$(printf '%s' "$PW" | sha256sum); unset PW [ "$A" = "$B" ] && echo "password intact" || echo "MISMATCH: stop and fix the secret" ``` Confirm the disk is thin and Remote Desktop is up (the address file path was observed in release 6.06; check yours): ```bash sudo du -sh --apparent-size /srv/win11/storage/data.img; sudo du -sh /srv/win11/storage/data.img sudo podman exec win11 bash -c 'IP=$(cat /run/shm/qemu.ip); timeout 3 bash -c "echo > /dev/tcp/$IP/3389"' && echo "RDP is up" ``` ### 5.4 After Windows reaches the desktop ```bash sudo sed -i '/^Secret=win11-password/d' /etc/containers/systemd/win11.container sudo podman secret rm win11-password sudo systemctl daemon-reload sudo systemctl restart win11.service # restarts Windows; do it when nobody is in a session ``` Then, inside Windows: activate it with a valid license, set the time zone, and turn off sleep. dockur signs Windows in automatically, so **never publish port 8006**; it is a recovery console for SSH tunnels only. Check Microsoft's license terms for running Windows in a virtual machine. ## 6. Guacamole as a pod (optional) A pod shares one network namespace, so guacd, the web app and PostgreSQL talk over `127.0.0.1`. The pod also joins the `win11` network, so guacd reaches Windows by the name `win11`. These units generated cleanly on all three Podman versions; the pod itself was not started in our test. ```bash sudo mkdir -p /srv/guac/db /srv/guac/init && sudo chmod 700 /srv/guac sudo sh -c 'umask 077; pw=$(openssl rand -hex 24); printf "POSTGRES_DB=guacamole\nPOSTGRES_USER=guacamole\nPOSTGRES_PASSWORD=%s\n" "$pw" > /srv/guac/db.env; printf "POSTGRESQL_PASSWORD=%s\n" "$pw" > /srv/guac/guac.env' sudo podman run --rm docker.io/guacamole/guacamole:1.6.0 /opt/guacamole/bin/initdb.sh --postgresql | sudo tee /srv/guac/init/001-initdb.sql >/dev/null ``` Write `/srv/guac/init/002-setup.sql` (with `sudoedit`) exactly as in section 4 of the Docker guide: named users, no `guacadmin`, no stored Windows password, connection hostname `win11`. Then: ```bash sudo chmod 755 /srv/guac/init && sudo sh -c 'chmod 644 /srv/guac/init/*.sql' # the postgres user must read them ``` `/etc/containers/systemd/guac.pod`: ```ini [Pod] PodName=guac Network=win11.network PublishPort=127.0.0.1:8007:8080 [Install] WantedBy=multi-user.target ``` `/etc/containers/systemd/guac-db.container`: ```ini [Container] ContainerName=guac-db Image=docker.io/library/postgres:16-alpine Pod=guac.pod EnvironmentFile=/srv/guac/db.env Volume=/srv/guac/db:/var/lib/postgresql/data Volume=/srv/guac/init:/docker-entrypoint-initdb.d:ro ``` `/etc/containers/systemd/guacd.container`: ```ini [Container] ContainerName=guacd Image=docker.io/guacamole/guacd:1.6.0 Pod=guac.pod ``` `/etc/containers/systemd/guacamole.container`: ```ini [Unit] Requires=guacd.service guac-db.service After=guacd.service guac-db.service [Container] ContainerName=guacamole Image=docker.io/guacamole/guacamole:1.6.0 Pod=guac.pod EnvironmentFile=/srv/guac/guac.env Environment=GUACD_HOSTNAME=127.0.0.1 Environment=POSTGRESQL_HOSTNAME=127.0.0.1 Environment=POSTGRESQL_DATABASE=guacamole Environment=POSTGRESQL_USERNAME=guacamole Environment=WEBAPP_CONTEXT=ROOT Environment=API_SESSION_TIMEOUT=300 # Header sign-in only behind Cloudflare Access with origin token validation; read the # trust note in the Docker guide before enabling these three lines. #Environment=POSTGRESQL_AUTO_CREATE_ACCOUNTS=true #Environment=HEADER_ENABLED=true #Environment=HTTP_AUTH_HEADER=Cf-Access-Authenticated-User-Email ``` Pin all three images by digest the same way as in 5.1. Then: ```bash sudo /usr/libexec/podman/quadlet -dryrun 2>&1 | grep -i -E 'error|unsupported' || echo "Quadlet files OK" sudo systemctl daemon-reload sudo systemctl start guac-pod.service # the pod service pulls in all three containers sudo podman ps --pod --format '{{.Names}} {{.PodName}} {{.Status}}' sudo podman logs guac-db 2>&1 | grep -E '002-setup|ERROR' sudo podman run --rm --network win11 docker.io/library/busybox:1.37 nc -z -w 3 win11 3389 && echo "RDP reachable from the win11 network" ``` The generated `guac-pod.service` carries `Wants=` for its three containers, so enabling the pod at boot (its `[Install]` section) starts the set. The member units need no `[Install]` of their own. ## 7. Front door and backups Nothing here changes with Podman. Follow sections 5 to 7 of the Docker guide: - **Cloudflare Tunnel and Access:** point the protected hostname at `http://127.0.0.1:8007` (Guacamole) and turn on Protect with Access. - **Out-of-band console:** confirm your provider's IPMI/KVM page works and set a local console password. - **Backups:** exclude `/srv/win11/storage` from file backups (a live disk image is large and not consistent while Windows runs). For a rollback point, stop the service and copy the image sparsely: `sudo systemctl stop win11.service && sudo cp --sparse=always /srv/win11/storage/data.img /srv/win11-rollback.img && sudo systemctl start win11.service`. Dump the Guacamole database instead of copying its files: `sudo podman exec guac-db pg_dump -U guacamole guacamole > guacamole.sql`. Podman secrets are not part of any backup. Keep the Windows password in a password manager. ## 8. Updates `podman auto-update` can update containers whose units say `AutoUpdate=registry` (fully qualified image required); Podman ships a daily `podman-auto-update.timer`. **Do not use it for the Windows container.** An image update restarts the container, which restarts Windows in the middle of whatever is open. Update by hand: ```bash sudo podman pull docker.io/dockurr/windows: sudo podman image inspect --format '{{index .RepoDigests 0}}' docker.io/dockurr/windows: sudoedit /etc/containers/systemd/win11.container # replace the digest in Image=; note the old one sudo systemctl daemon-reload && sudo systemctl restart win11.service # at a quiet time ``` To roll back, put the old digest back, reload and restart. Remove old images a week later with `sudo podman image prune`. ## 9. Moving from Docker Compose Most Compose keys map one to one: | Compose | Quadlet (`[Container]` unless noted) | Note | |---|---|---| | `image: dockurr/windows` | `Image=docker.io/dockurr/windows@sha256:` | Fully qualified | | `container_name` | `ContainerName=` | | | `environment` | `Environment=` (one per line) | | | `env_file` | `EnvironmentFile=` | Values are literal; a `$` stayed a `$` in our test | | `secrets` | `Secret=name,type=env,target=VAR` or `Secret=name` (file) | Create with `podman secret create` | | `devices` | `AddDevice=` | | | `cap_add` | `AddCapability=` | | | `ports` | `PublishPort=` | | | `volumes` (bind) | `Volume=/host:/container` | `:Z` on SELinux hosts | | `volumes` (named) | `Volume=name.volume:/path` plus a `name.volume` file | | | `networks` | `Network=name.network` plus a `name.network` file | | | `user` | `User=` | | | `labels` | `Label=` | | | `healthcheck` | `HealthCmd=` and related keys | | | `stop_grace_period: 2m` | `StopTimeout=120` | Raise `TimeoutStopSec=` in `[Service]` to match | | `restart: unless-stopped` | `[Service] Restart=on-failure` plus `[Install] WantedBy=` | | | `depends_on` | `[Unit] Requires=` and `After=` | | | several services sharing localhost | a `.pod` file and `Pod=` | | **Moving an existing Windows disk (not tested by us).** dockur keeps Windows in its `/storage` folder, so the same image release should boot an existing disk under Podman. Do it on a copy first: ```bash sudo docker compose -f down # stop the Docker container; ports and disk are now free sudo mkdir -p /srv/win11/storage && sudo cp -a --sparse=always /. /srv/win11/storage/ # Use the SAME dockurr/windows release the disk was installed with. Leave out the Secret= line. sudo systemctl daemon-reload && sudo systemctl start win11.service ``` Keep the Docker files untouched until Windows has booted under Podman and you have signed in. **Keeping Compose instead.** `podman compose` is a wrapper that needs an external provider (`docker-compose` or `podman-compose`). Alternatively, enable Podman's Docker-compatible API and point Docker Compose at it: ```bash sudo systemctl enable --now podman.socket sudo DOCKER_HOST=unix:///run/podman/podman.sock docker compose up -d ``` The podman-compose project notes that this works with unmodified Docker Compose "but in this case you lose the process-model". Quadlet units keep it. ## 10. Rootless variant (not tested on a bare host) Rootless keeps every container process under your own account. For Windows it adds friction: 1. **KVM access.** `/dev/kvm` belongs to the `kvm` group. Add yourself (`sudo usermod -aG kvm $USER`, then sign out and in) and pass the group into the container with `GroupAdd=keep-groups` (crun only). 2. **ID ranges.** `grep $USER /etc/subuid /etc/subgid` should show a range such as `100000:65536`. 3. **Unit location.** Put the files in `~/.config/containers/systemd/`, use paths under your home for `Volume=`, and change `[Install]` to `WantedBy=default.target`. 4. **Commands.** `systemctl --user daemon-reload`, `systemctl --user start win11.service`, and `sudo loginctl enable-linger $USER` so the service starts at boot without a sign-in. 5. **Networking.** dockur's base project notes that with "user-mode networking (for example when running under Podman)" every extra port must be listed in `USER_PORTS`, for example `Environment=USER_PORTS=22,80`. 6. **Ports.** Rootless containers cannot publish ports below 1024 by default; 3389, 8006 and 8007 are fine. Our only rootless attempt ran inside another container and failed at dockur's first check of `/proc`. That points at the nesting, but it is not proof either way. Prove rootless on your own host before depending on it. ## 11. Troubleshooting - **"KVM acceleration not available".** Re-run `kvm-ok`. Check that virtualization is on in the firmware, and that you are on bare metal or a host that allows nested virtualization. dockur's README suggests a temporary privileged run to rule out a permissions problem; with Quadlet that is `PodmanArgs=--privileged`. Remove it afterwards. - **"Microsoft blocked the automated download request".** Wait, or use your own ISO (section 4). - **A short-name error or prompt.** Use the fully qualified image name. - **"address already in use".** Another container, often the old Docker one, still publishes the port. - **The service does not exist after you wrote the file.** Run `sudo systemctl daemon-reload`, then the Quadlet dry-run command to see the generator's errors. - **`podman compose` lists three paths and stops.** It found no provider; install one or use the socket (section 9). - **`podman logs` shows nothing.** Check the log driver: `sudo podman info --format '{{.Host.LogDriver}}'`. With `journald`, read `journalctl -u win11.service`. ## 12. Final checklist - [ ] `kvm-ok` passes and `/sys/fs/cgroup` is cgroup2. - [ ] Every image is fully qualified and pinned by digest. - [ ] Ports 8006, 3389 and 8007 are published on `127.0.0.1` only. - [ ] The Quadlet dry-run shows no errors; `systemctl status win11.service` is active. - [ ] The password hash check said "password intact"; the secret and the `Secret=` line are gone after install. - [ ] Windows is activated with a valid license. - [ ] `/srv/win11/storage` is excluded from file backups; a sparse rollback copy exists. - [ ] The Guacamole database is dumped on a schedule, if you run the pod. - [ ] Image updates are manual, with the previous digest written down. ## Sources - Podman: https://docs.podman.io/en/latest/markdown/podman.1.html, https://docs.podman.io/en/latest/markdown/podman-run.1.html, https://docs.podman.io/en/latest/markdown/podman-systemd.unit.5.html, https://docs.podman.io/en/latest/markdown/podman-secret-create.1.html, https://docs.podman.io/en/latest/markdown/podman-auto-update.1.html, https://docs.podman.io/en/latest/markdown/podman-compose.1.html, https://docs.podman.io/en/latest/markdown/podman-system-service.1.html, https://docs.podman.io/en/latest/markdown/podman-pod-create.1.html, https://docs.podman.io/en/latest/markdown/podman-kube-generate.1.html - Podman project notes: https://github.com/podman-container-tools/podman/blob/main/RELEASE_NOTES.md, https://github.com/podman-container-tools/podman/blob/main/rootless.md, https://github.com/podman-container-tools/podman/blob/main/docs/tutorials/rootless_tutorial.md, https://github.com/podman-container-tools/podman/blob/main/docs/tutorials/basic_networking.md - Registries and storage: https://github.com/containers/image/blob/main/docs/containers-registries.conf.5.md, https://github.com/containers/storage/blob/main/docs/containers-storage.conf.5.md - podman-compose: https://github.com/containers/podman-compose - Windows container: https://github.com/dockur/windows, https://github.com/dockur/windows/blob/master/docs/environment.md, https://github.com/qemus/qemu - Guacamole: https://guacamole.apache.org/doc/gug/guacamole-docker.html - Test records: our own runs on October 14, 2026, described in the companion article.