← Back to Wiki
Self-Hosting / Game Servers

Run a "Windows-Only" Dedicated Server on Plain Linux

Some games ship an "official" self-hosting tool that flatly requires Windows plus Hyper-V, nested virtualization and all. Open the box before you believe that. The actual payload is often a portable Linux disk image with a full container orchestration stack already inside it. The Windows and Hyper-V wrapper is there because that is what the publisher's own tooling was built on.

Share on X

Look inside the box first

A modern "dedicated server" for a live-service game is rarely a single executable. It is usually a small fleet of coordinated services bundled with something like Kubernetes to manage them. A database, a message queue, matchmaking and gateway logic, the game-world process itself. If the official installer requires Windows, check what it actually ships before you assume you need a Windows box. Publishers have said as much publicly for at least one game using this pattern. Linux-direct hosting works fine. They just do not provide official instructions, because their own packaging tool needs Windows and Hyper-V to unpack it.

Extract and boot the real payload directly

Once you confirm the official installer is only unpacking a self-contained disk image, a VHDX in the case this is based on, convert it to your own hypervisor's format and boot it directly:

qemu-img convert -f vhdx -O qcow2 server-disk.vhdx server-disk.qcow2
qemu-img resize server-disk.qcow2 80G

Boot that directly under KVM, or whatever your hypervisor is. No Windows. No Hyper-V. No nested virtualization anywhere in the chain.

BE WARNED: configure networking before first boot, not after. The orchestration layer inside the image reads its network config once at first startup and bakes the result into its own internal settings. Let it boot once with a wrong address, a DHCP-assigned one for instance, and pod and container networking breaks. It does not self-correct when you change the config later. Mount the disk offline via NBD and fix the network config, DNS and kernel modules before you ever power it on:
modprobe nbd max_part=8
qemu-nbd --connect=/dev/nbd0 server-disk.qcow2
vgchange -ay vg0
mount /dev/mapper/vg0-lv_root /mnt
# edit /mnt/etc/network/interfaces for a static IP
# check /mnt/etc/resolv.conf -- shipped images often point at their original
# host environment's internal DNS, which is dead anywhere else
umount /mnt; vgchange -an vg0; qemu-nbd --disconnect /dev/nbd0
A cert-manager-style TLS error on first world creation usually self-heals. Something like x509: invalid signature: parent certificate cannot sign this kind of certificate right after first boot looks alarming. It is usually clock skew during early startup, before NTP has synced. Wait a few minutes and retry the same command rather than troubleshooting further.
BE WARNED: a custom startup wrapper may cache the node's own IP at boot only. Change the host's network config after the fact and restarting the application layer is not enough. Some of these images have their own startup script that reads network config once, at their own service's startup, and bakes it into internal flags. That is completely separate from the container orchestrator's restart cycle. Restart only the containers and pods and the stale IP stays baked in indefinitely. Restart the underlying service first, then the application layer on top of it.

Self-hosting the server doesn't make you independent from the publisher

This caused a real, confusing bug. It is worth walking through in full, because the debugging process is the useful part, including two wrong turns along the way.

The symptom. A player transferred their character onto a self-hosted world, then got a generic "Claim failed, try again later" error redeeming a returning-player reward package. Even after waiting over an hour, as the game's own message suggested.

First finding, and it matters for anyone running one of these. A self-hosted world is not isolated from the publisher's live infrastructure. The game-world processes make real outbound HTTPS calls to the publisher's cloud backend for entitlements, transfers and reward systems. None of that is simulated locally just because you host the "dedicated server" part yourself. The failure was findable in the game process's own logs, in a category clearly labeled as talking to the publisher's live services. Not anywhere in the hosting or orchestration layer's logs:

LogFuncomLiveServices: Error: https://[publisher-live-service-host]/api/ClaimSystem_GrantPacksForCharactersServer... Failed: Request Timeout or null response
LogDuneCharacter: Error: ...GrantReturnRewards... Failed to grant returning-player award packs for the character...
Wrong turn number one. I assumed a transient network blip. I called the same endpoint directly from inside the running game process and got a clean, fast response. It looked like the failure had passed. It recurred identically on the player's very next real attempt. A single clean manual test confirms nothing is fixed. Only a real repeat attempt does.
Wrong turn number two. I guessed a fundamental self-hosting limitation. That the publisher's backend silently rejects reward claims from unofficial servers as an anti-abuse measure. It was never confirmed, and it was wrong. Worth naming explicitly, because it is exactly the kind of plausible-sounding theory that is easy to accept without evidence. Especially once one wrong theory has already burned time.

The actual answer came from the publisher's own patch notes and player community reports, not from guessing. This was a documented bug. Transferring a character to a new world without first logging into the character's origin world to view the reward there resets its eligibility. The publisher had shipped a fix for the common case months earlier. It has a real edge case. If the character's origin world was already shut down by the time of the transfer, there is nothing left to log into first, and the fix cannot apply retroactively. The only resolution other players in that situation reported success with was an official support ticket requesting manual compensation. Nothing fixable through local server configuration.

Checking your build is current without needing real credentials

If the disk image comes through a platform like Steam rather than a bespoke publisher download, you already have a lightweight way to check whether you run the latest build. No full re-download, no real login:

steamcmd +login anonymous +app_info_print <appid> +quit

Look for the branches → public → timeupdated field in the output. It is a Unix timestamp, so pipe it through date -u -d @<value> to read it. Compare that against when you built your deployment. If the publisher's build predates your deployment date, you are current and a stale binary is ruled out. Check that before you assume an obscure bug needs a version bump to fix.

The general lesson from the whole investigation. Your infrastructure being completely healthy does not mean a gameplay bug is happening inside your infrastructure. Check the game process's own logs first, specifically whatever category handles calls to the publisher's live-service backend. Then hold your first plausible-sounding theory loosely. This one took three attempts before landing on the truth. A transient blip, then an unconfirmed self-hosting-limitation guess, then the real documented bug found through actual research.

Silently stale: when an automated update job "succeeds" without updating anything

A separate incident, months later. A server that had run fine for days stopped showing up in the in-game server browser at all. Not a connection failure. Not a timeout. Just gone from the list. Every layer under direct control checked out healthy. The world simulation was running. The periodic calls that publish the server to the browser returned clean responses. The tunnel was reachable. DNS resolved correctly. The database showed no blocked state. Two real, unrelated bugs got found and fixed along the way. Fixing them did not bring the server back.

BE WARNED: the actual cause was hiding behind a command that "worked". A daily cron job was supposed to keep the server updated automatically, and its logs showed clean runs with no errors, every day. "No error" and "did nothing" were the same outcome here. The job used an anonymous login path that intermittently failed in this exact non-interactive context, before it ever reached the update step, and that failure never surfaced as a visible error in the log output. The server had been stuck on its original build the entire time. The publisher shipped a new build on the exact day the listing problem started.

Found by re-checking build freshness directly, with the same anonymous app_info_print trick from above. Not by trusting an earlier pass that had already "confirmed" the build was current. That earlier check only confirmed the update job ran without erroring, which is not the same as confirming it updated anything.

Fix: run the update command by hand, interactively over SSH instead of through cron. It worked immediately and pulled the new build cleanly, which confirmed the automated job's failure mode was specific to running non-interactively. Note that applying an update to this kind of orchestrated server often leaves the world itself stopped. Updating and starting are two separate steps, easy to forget when you are used to the orchestrator handling both.

The lesson worth keeping. A scheduled job that fails safely, with no crash, no error and no impact on the running service, looks identical to one that succeeded. Right up until you need whatever it was supposed to have done. If something "should already be up to date" because an automated job says so, re-verify that at the moment it matters. Do not assume it is clean because nothing ever complained.