mabel.ink

A game server that costs nothing when nobody is playing

September 21, 2026

I ran a Valheim dedicated server on Azure for a few weeks so some friends could play together. The game was the excuse. The real exercise was building something that costs roughly nothing while idle, and then finding out where that idea holds up and where it does not.

Why containers and file shares

A stopped Azure Container Instance group bills nothing. No disk, no reserved IP, no minimum. A stopped VM still bills for its disk and its static address, which I estimated at $8 to $22 a month even when deallocated. So the design is a container group that exists only while someone is playing, with the world saved on an Azure Files share that outlives it.

Compute was about $0.11 an hour for 2 vCPU and 4 GB. Stopped, the only cost was storage, roughly $0.20 a month. Left running around the clock it would be about $81 a month, which is more than the game was worth to me.

The price of that design is slow file access. Azure Files mounted into a container is badly suited to thousands of small files. Microsoft's own support case measured 10,000 tiny files at 602 seconds on the share versus 0.75 seconds on local disk. A lot of the odd workarounds in the repo trace back to that one fact.

What it actually cost

The real number was higher than my early docs implied, because I had only ever written down the hourly rate. When I finally measured it from per-minute metrics, the server had run 87.5 hours in a week, about $9.43, or roughly $40 a month. Auto-stop was working fine. The group was simply in use 12 to 17 hours a day.

I do not have a record of the total across the whole project, so I will not invent one. What I can say is that the month was in the tens of dollars, and the final bill after teardown was $0.00.

Two auto-stop layers that disagree on purpose

The cost model only works if the server reliably turns itself off. I built two layers:

Neither stops a server it can see players on. The interesting part is what each does when it cannot read the player count at all.

Unreadable count Protects
Idle check treat as in use, never stop the players
Nightly stop treat as stop the bill

The idle check has to fail safe for people, otherwise a broken status endpoint kills a populated server mid-fight. But that same rule leaves a hole: if the endpoint is broken, an empty server bills indefinitely and nothing notices. The nightly run closes the hole by choosing the opposite answer. If I made both layers agree, the second one would be pure duplication. The reasoning is written at the top of the runbook file, so a future me does not "tidy" it away.

A small side note on the plumbing. Azure Automation's minimum schedule is one hour, so 15-minute polling means four hourly schedules offset by 15 minutes. Calling the Azure API directly instead of loading the PowerShell modules cut each job from about 45 seconds to 5 or 6, which keeps the whole thing inside the free tier.

The region move, measured

I had put the server in a Virginia region for a reason I could not remember. It turned out to be a leftover from an early storage decision. Everyone playing was in Texas.

So I measured instead of guessing: 12 TCP handshakes per region, run twice, from a player's machine. Virginia came back at a 39.0 ms median. South Central US came back at 6.0 ms. After the move, the in-game ping read 4 ms.

It cost about 20 percent more, $0.1292 an hour against $0.1077, which works out to roughly $8 a month at that usage. I put the new region in its own resource group, which meant both could run at the same time during the cutover. Rollback was "leave the old one alone". The cutover itself took 85 seconds.

A Discord button, and why it was safe

Players could start the server themselves with a slash command in Discord, handled by a small bot running on my home server. Handing friends a start button is only reasonable because auto-stop caps the damage. A start nobody joins stops itself within 30 to 45 minutes, so a mis-click costs about eight cents.

The bot was retired once the cloud side was gone. Its start and stop commands only knew how to talk to an Azure container group. Reviving it for a self-hosted server would have meant giving it access to the Docker socket, which is a real privilege escalation compared to a credential that could only start and stop one container group. And with no bill to bound and no auto-stop, the argument for a public start button would be gone too.

How it ended

The group finished the game, so I deleted everything: both resource groups, the storage, the container groups and the Automation account. The subscription now holds zero resources.

Before deleting, I pulled the world down, about 410 MB, and test-extracted one of the backup tarballs to confirm it produced the same files as the loose copy. An untested backup is not a backup, and this was the one deletion I could not undo.

I had also built and parked a self-hosted fallback early on, the same server as a Docker Compose stack on my home server. It had a 50 second warm start against 160 seconds on Azure. I held off on adopting it because it inverts the dependency: with Azure, my server being down only meant nobody could start the game, but self-hosted it would mean nobody could play. When the cloud copy went away, that parked fallback was the obvious place to go next.

What I would keep from the whole thing is the shape of the cost model. Pay for compute only while it is in use, decide what an unknown reading means before it happens, and write the answer down where the next person will see it.