WTS Valheim
WTS Valheim is the website for Deathsquito, a private, modded Valheim server my friends and I play on. It answers the questions that used to land in group chat: is the server up, how many people are on, which mods do I need and at what versions, and what settings is the server running. Trusted players can also start, restart, reconfigure and mod the server themselves instead of waiting for me, and the group's Discord hears about it when they do.
What It Is
A small static site with a serverless API, hosted on Azure Static Web Apps, sitting in front of a Linux VM that runs the dedicated server. It has four public pages and one for operators:
- Overview: a quick status check and the live world settings (Raids, Resources, and any other modifier, preset or world key), read from the running server
- Mods & Setup: one-click profile codes for mod managers, the required and optional client mods, and every server-side mod with the version the server loaded and whether Thunderstore has a newer one
- Config: a readable view of each server-side mod's live config, with search and a "changed from default" filter
- Status: VM power state, game state and player count, refreshed automatically, plus a Start server button for operators
- Operator tools: start or restart the game or the VM, edit mod configs, install, update, disable, remove or roll back server mods, manage the profile codes players use, check who's playing and how the VM is doing, set up Discord notifications, and an audit log of every action
The design goal was to make the server self-service for the group without handing anyone Azure access or a shell, without putting any keys on the VM or in the repo, and without a mods page that someone has to remember to keep up to date.
Architecture
Browser
│
▼
Azure Static Web Apps (free tier)
├── site/ static HTML, CSS, JS (strict CSP, no third-party scripts)
└── api/ managed Azure Functions (Node 22)
├── GET /api/status VM power state + heartbeat
├── GET /api/world world modifiers the server started with
├── GET /api/mods server mods + required client mods
├── GET /api/profiles saved profile codes and how they compare
├── GET /api/config/{mod} parsed BepInEx config for one mod
├── POST /api/start operator role only
└── /api/operator/* operator role only: commands, configs,
│ mod installs, profiles, players,
│ server stats, webhooks, audit log
├── Thunderstore API versions, packages and profile codes
├── Gale API synced profile contents
├── Discord webhooks operator actions, new codes
│
│ service principal: read, start and restart this VM only
▼
Azure Resource Manager ── Valheim VM
│ managed identity
│ valheim-ops agent (15 s timer)
│ ── Discord webhooks: online, offline,
│ failures, rollbacks
▼
Blob Storage
├── status/ heartbeat, loaded mods, installed
│ mods, world settings, script hashes
├── ops/ command.json (the lock), cooldown,
│ payloads, results, audit log,
│ mod flags, profiles, webhooks
└── valheim/bepinex-config/*.cfg (versioned)
Status Without Querying the Game
The server runs with -public 0, so it doesn't answer Steam A2S queries and can't be polled from outside. Instead, the VM reports on itself.
A systemd timer runs a heartbeat script every minute. It reads the game state from systemd (starting, online, stopping, stopped) and scans the journal for the current run only, keyed on the service's invocation ID:
Game server connectedmeans the world is loaded and joinableConnections N ZDOSgives an authoritative player count, logged about every 10 minutesNew connectionandClosing socketadjust that count between snapshots
The result is uploaded as heartbeat.json with the VM's managed identity, using a token from the instance metadata service and plain curl. The token is passed through a header file rather than argv, so it never shows up in ps.
GET /api/status combines that heartbeat with the VM power state from Azure. A heartbeat older than three minutes, or one that fails validation, is reported as unknown rather than trusted. The comparison uses an absolute time difference, so a VM clock running ahead can't keep stale data alive. The script also fails loudly if it loses journal access, so a permissions problem shows up as a stale heartbeat instead of a server that looks like it's "starting" forever.
World Settings From the Running Server
The Overview used to have a hardcoded "Raids: Disabled" card. Now the heartbeat reads the running server's command line and keeps only -preset, -modifier <key> <value> and -setkey <key>. It skips the value after -password and accepts plain words only, so the server password never leaves the VM. The result is uploaded as world.json, only when it changes.
GET /api/world validates it again: keys and values must be short plain words starting with a letter, which also rules out prototype-pollution keys like __proto__. The Overview then shows Raids and Resources (with Valheim's drop rate, for example "More (1.5×)") plus a card for any other modifier, preset or world key.
Valheim reads each modifier value as a single word, so a typo silently falls back to the default. That actually happened: the server was started with resources much more instead of muchmore, so it received much. The Overview now checks each value against the ones Valheim accepts and shows Default ("much" isn't a valid value) instead of pretending the setting worked.
A Mods Page That Builds Itself
The server now needs matching client mods, and a hand-written mods table went stale whenever the server changed. The page is now built from what the server actually runs.
What the server loaded. BepInEx's load lines never reach the journal, so once the server is online the heartbeat reads the BepInEx version and every Loading [Name Version] line from LogOutput.log. BepInEx rewrites that file on each start, so a log older than the current service start is ignored. The result is published as mods.json, only when the plugin list changes. A failed upload never fails the heartbeat and never updates the local cache, so it's retried on the next run.
What each mod is. GET /api/mods joins that list with a small mod catalog (title, author, Thunderstore package, description and config page) and with each package's latest version from the Thunderstore API. Lookups have a 5-second timeout and are validated. Results are cached for an hour and failures for five minutes, so a Thunderstore outage only means no update badges. The Server-side mods table is rendered from that response:
- Every loaded plugin appears. One the catalog doesn't know shows "Details not added yet", so the table can't quietly miss a mod.
- Catalog mods the server didn't load show not loaded.
- An Update available: x.y.z badge appears when Thunderstore is ahead of the server.
- If the API is down, the table says so instead of showing stale rows.
Plugin versions turned out to be unreliable: some authors don't bump the version their plugin reports (Ammunition Piles 1.0.3 calls itself 1.0.2), which produced a false "Update available". Mods installed through Operator tools now show the installed package version instead, and a change made since the server's last start shows the running version plus "new after restart". Some plugins' names look nothing like their package, so the VM's inventory also looks an unmatched plugin's name up in the DLLs of mods without a known plugin (.NET stores the BepInPlugin name there) and claims it only when exactly one mod's DLLs declare it. Before that, those mods showed up twice.
What players need. The Required client mods table started as a snapshot generated from an r2modman profile, which went stale the first time the server moved on: it kept listing Zen World Settings 1.13.4 after the server ran 1.13.5. It's now built by the API from what the server actually runs: every server mod players need, plus the dependencies installed for them, at the version the server has loaded (and the next one around a pending restart). Whether players need a mod comes from an operator's Players need it too flag, falling back to the catalog. Thunderstore has no reliable field for that, so the install dialog asks and pre-ticks it from the package's Server-side and Client-side categories. BepInExPack is always listed, and any pack version counts, because the pack and the core number their versions differently. An Optional client mods table is kept by hand for mods the server doesn't check for, such as one that keeps Steam achievements working with mods.
Quick setup. The fastest way in is at the top of the page: an r2modman profile code, and Gale synced profiles maintained by players in the group, one with just the required mods and one with a curated set of optional extras on top. Each has a Copy button, which only appears when the clipboard API is available and loads from a separate script so it fits the Content Security Policy. Every code is compared with the required list, and the page points players to a code that matches, or to the table if none does. Operators can change the codes themselves (see below), so the page never has to be redeployed for a new profile.
Updates are never automatic. Most server mods are also required on clients, so an unplanned server update would kick every player. The update badges say when to update, and an operator chooses the moment from Operator tools (below). For mods players need too, the update dialog lists what else to refresh: the r2modman code and both Gale profiles.
Live Server Configs
A second timer runs azcopy sync every 15 minutes to push the server's BepInEx/config folder to Blob Storage, skipping images that some mods ship as default data. Blob versioning keeps every previous version of every config, which gives a free backup and change history. azcopy leaves a job plan and log files behind on every run, about 2 MB a day, so the sync script now prunes plans older than a day and logs older than a configurable week each time it exits.
The Mods page links each server-side mod to a config page. The API parses the BepInEx .cfg format into sections, settings, types, defaults and acceptable ranges, and flags which values differ from the default. Long configs start collapsed, each section shows how many settings changed, and search or the "changed only" filter opens the matching sections. Each page also shows when the config last changed on the server. That's the blob's last-modified time, which works because azcopy sync only re-uploads files that changed. Two layers keep this safe to show anonymous visitors:
- An allowlist: only listed config files can be read. The API never builds a blob path from user input.
- Redaction: settings whose name or value looks like a credential (passwords, tokens, webhook URLs, API keys, auth keys, RCON settings, connection strings, signed URLs) or lists people (admin and ban lists, Steam IDs) are never sent to the browser.
New mods installed from Operator tools get config pages automatically. Their configs are identified by the BepInEx header inside the file, never by file name.
Operators can also edit these configs from the same page (see below).
Operator Tools
The first version let operators start the server through Azure Run Command, which means "run anything as root on the VM". The operator tools replaced it with something much narrower, built in five phases.
As the tools grew, the page was reorganized around a status bar that stays in view (game status, players, Restart service or Start server, with Restart VM and Check agent under More) and tabs for Server mods, Mod profiles, Players, Server status, Discord and Activity. The selected tab is kept in the address so links land on the right one, and long explanations fold away until you open them.
Starting the server. The VM doesn't need to run when nobody's playing. POST /api/start picks the right action:
- VM stopped or deallocated: start the VM. The game service is enabled, so it starts on boot.
- VM running, game stopped or unknown: queue a
start-servicecommand for the VM agent. - VM or game starting or stopping: report busy and ask them to wait.
- Game already online: say so and do nothing.
The decision logic is a pure function with unit tests, separate from the Azure calls.
Phase 1: a command queue instead of a shell. Anything that runs on the VM goes through a queue in Blob Storage, worked by a small agent (valheim-ops) on a 15-second systemd timer:
- The API creates
command.jsononly if none is pending, so the file itself is the lock. A stale command can only be replaced with a conditionalIf-Matchwrite. - Server-changing actions claim
cooldown.jsonwith a conditional write, so two can't run within 60 seconds of each other, even two VM restarts clicked at the same moment. - Every action is written to a monthly audit log before it runs. If the audit write fails, nothing happens. The page shows the last 25 entries and their results.
- The agent runs as
steam, accepts only the exact format the API writes, runs only allowlisted actions, and enforces its own expiry and cooldown. It records each outcome locally before uploading it, so a command never runs twice, and an interrupted run is reported as failed. sudoers allows exactlysystemctl start|stop|restart valheimserver. - Restart VM is the one action that calls Azure directly, as a fallback if the agent itself is stuck. The confirmation dialog warns when players are online.
Phase 2: editing configs. Each config page has Edit settings for operators. Every setting gets an input that matches its type: on/off, listed values, ranges, flag checkboxes, colors or text. Values are checked as you type, and secret-looking settings are never editable. Review and save lists every change as old → new, with "restart the game service after saving" off by default.
The change set is validated in the API, saved as a payload and queued. On the VM, a root-owned Python script (standard library only, run with python3 -I) checks everything again against the live file: the file is allowlisted, each setting exists exactly once, each value passes the same rules, and each value is still what the operator saw. The JavaScript and Python validators share one file of 72 test cases so they can't drift apart. The script backs up the exact bytes it read, writes atomically, aborts if the game touched the file in the meantime, and changes only the edited lines while keeping line endings and BOM. Undo last change re-applies the old values through the same checks. A save without a restart shows a pending-restart banner until the game service next starts.
Phase 3: updating server mods. The Server mods tab offers each mod's latest or previous Thunderstore version, and a rollback of the last mod change. A row shows an Update button only when a newer allowed version exists. BepInEx itself stays a manual update. A shared policy file, checked by both the API and the VM, decides what's allowed: authors not on the trusted list must wait 48 hours after a release, dependencies must already be installed, and withdrawn versions are blocked.
The installer downloads only from Thunderstore's CDN, with a size check and a 5-minute deadline, and unpacks safely: no symlinks, no .. or absolute paths, and limits on size and file count. With a restart (the default), the agent:
- stops the server
- backs up the world
- swaps the mod's plugin folder, keeping the old one
- starts the server
- waits up to 5 minutes for it to come online with the new version and every mod that was loaded before
If that doesn't happen, it puts the old folder back and starts the server again on its own. The heartbeat also publishes installed.json, the versions on disk with each mod's plugins and configs, so the page can tell "installed" apart from "loaded".
Phase 4: installing and removing mods. Operators can search Thunderstore (or paste a link), check a mod against the policy, and install it with its dependencies after confirming the dependency list. Installed mods can be disabled, enabled and removed, but not while another mod needs them, and Roll back last mod change covers all of it. The policy got stricter for new packages: Valheim packages only, not deprecated, at least 30 days on Thunderstore and 1,000 downloads, with versions out for 48 hours. Dependencies are gated the same way, and a short list of trusted authors skips the age and download checks.
On the VM, install-mod.py plans each change as folder moves recorded in the backup, works out the dependency list itself, and refuses if it differs from the one the operator confirmed. Nothing may leave a mod without its dependencies. After a restart, every mod that loaded before must load again (minus the ones removed), plus a plugin from the new mod, or everything is put back.
Some packages ship default data in a config/ folder that r2modman copies into BepInEx/config. The installer now does the same, for data files only (images, .cfg, .yml, .json and so on), never code or BepInEx.cfg. A mod owns the files it installed, tracked by SHA-256. An update replaces only the files nobody has edited and removes ones the new version dropped, while edited or foreign files are kept and named in the result. Replaced files go into the change's backup, so rollback restores them without undoing edits made since.
Mod profiles. The r2modman code and both Gale codes are edited from Operator tools. A new code is looked up first (the Thunderstore profile is downloaded and its export.r2x parsed; the Gale profile must be for Valheim), and a dialog lists the mods added, removed and updated before anything is saved. Parsing is defensive: size limits (40 MB for r2modman codes, which bundle the whole config folder, and 5 MB for Gale metadata), redirects allowed only to Thunderstore, and YAML parsed without aliases. Each code gets a Match or Different badge against the required list, with what's missing, extra or on another version. Gale profiles can change under the same code when their owner updates them, so the tab rechecks when it opens and has a Recheck button, and that fresh answer also refreshes what the public Mods page shows.
Players. Check players queues a read-only command; the agent reads the game service's journal and returns who's online and which characters have played in the last 30 days. Nothing is collected on a schedule, and results expire after 30 days like every other result. The server log names a character without its SteamID, and matching them by join order turned out to put some characters under the wrong player, so for now the tab shows characters only: online now, and recently seen, one row per character. The activity log records each check by count, never by name.
Server status. A second read-only command reports CPU, memory used and free, the game service's own CPU and memory (from its cgroup), and storage per filesystem. The tab checks when it's opened if the last report is more than five minutes old, and on Refresh.
Phase 5: Discord notifications. Operators can add, test, switch off and remove up to 10 Discord webhooks, each with its own choice of notifications. The API posts operator actions once they've gone through, a "restarting" heads-up for players before anything that restarts the game, and new r2modman codes with what changed. The VM posts when the game comes online or goes offline unexpectedly, and when a command fails or a mod change rolls itself back, saying whether putting the mods back worked. It knows a planned stop from a crash: a stop within 10 minutes of an announced restart, a VM reboot (told apart by its boot ID) and a VM shutdown aren't posted. Notifications are best effort: an action waits at most 3 seconds for them and never fails because of them.
Keeping the VM in sync. One deployment updated the agent on the VM but missed the config script, so every config save failed with only "The config change failed on the VM." Now a failing script's last error line is included in the result, and the heartbeat publishes the SHA-256 of every installed VM script and systemd unit. The API compares them with the hashes from the deployed commit and Operator tools shows a banner naming any file that's out of date.
Built for Phones Too
Most players check the site from their phone, so it got a readability and accessibility pass. The mod tables stack into one labelled block per mod on small screens, with explicit table roles so screen readers still treat them as tables. The nav wraps cleanly, no page scrolls sideways, keyboard focus is always visible, and the site has its own Valheim favicon.
Security Model
Most of the work went into keeping every identity as narrow as possible.
- Auth: Static Web Apps' built-in GitHub and Microsoft login. Anyone can view status;
/api/start, the operator page and every/api/operatorroute require theoperatorrole in the route rules, and each function checks the role again itself. Operators are invited by role with an expiring invitation. - CSRF: operator POSTs must be
application/json, which a cross-site form can't send. Request bodies are capped at 64 KB. - API identity: the free SWA tier can't use managed identity for its API, so the API uses a service principal. Its custom role can only read, start and restart this one VM. It used to include Run Command; once the queue was proven live, that permission was removed, so a leaked secret can no longer run arbitrary commands as root.
- Storage access: the service principal can read the status container. Config reads are scoped to the BepInEx config prefix by an ABAC condition on the role assignment, so even a bug in the API couldn't read other blobs.
- Split queue permissions: custom data roles with path conditions. The API can write only the command, cooldown, payloads, audit log, mod flags, profile codes and webhooks, and can't delete anything. That list once missed
webhooks.json, so saving the first Discord webhook got a 403 from storage. Now a test runs every kind of write the API makes and checks each blob path against the role's condition, so a new file can't be left off again. The VM can write only results and delete only the command, so it can't touch the audit log. Soft delete keeps overwritten audit entries for 30 days. - Defense in depth on the VM: the agent's action allowlist, a narrow sudoers rule, and root-owned scripts that re-check every config edit and mod update against the live file, Thunderstore and their own copy of the policy, never trusting the API alone.
- No keys on the VM: the heartbeat and config sync both authenticate with the VM's system-assigned managed identity.
- Nothing sensitive leaves the VM: the world settings come from an allowlist of arguments, and the password's value is skipped explicitly. Player and server reports exist only in operator-only command results.
- Write-only webhooks: only Discord webhook URLs are accepted, each is checked with Discord before saving, and no API response, activity entry or log ever includes one. Messages never ping anyone, outside text is escaped, and redirects and proxies are refused on both the API and the VM.
- Everything from the VM is validated twice: once when the script builds it and again in the API, with length limits, entry caps and unknown fields dropped.
- Headers: a strict Content Security Policy (
selfonly, no inline scripts),nosniff,frame-ancestors 'none', and a tight referrer policy. - Caching as protection: the anonymous status endpoint caches for 15 seconds and shares one in-flight request across callers, so a busy page can't hammer Azure Resource Manager. Thunderstore lookups are cached per package for the same reason.
Infrastructure and Delivery
infra/setup.sh: an idempotent script that creates the storage account and containers, enables versioning, soft delete and lifecycle rules, assigns the VM's identity, and creates the Static Web App. It also creates or updates the custom roles and replaces any role assignment whose ABAC condition has changed. It handles secret rotation and runs cleanly from Windows Git Bash.valheim-scripts/install.sh: installsazcopy, the sync, heartbeat and ops agent scripts, the config and mod installers, the player, server-stats and Discord helpers, the sudoers rule and their systemd units on the VM.tools/vm-scripts: records the hashes of the VM scripts for each commit, and a test fails if they're out of date, which is what powers the out-of-date banner.- GitHub Actions: every push and PR runs the API tests (100% line coverage), the Python tests for the VM scripts, and
shellcheckon all the Bash, then deploys. Pull requests get their own preview environment, and changes land ondevelopbeforemain. - Reviews: each operator phase went through a security review and a code review before merging, with every critical, high and medium finding fixed. The agent and installers were also tested in containers against a fake blob store,
sudo,systemctland journal, including failed updates that have to roll themselves back.
Stack
- Azure Static Web Apps: hosting, built-in auth, role-based route rules, PR preview environments
- Azure Functions (Node 22): the managed API
- Azure Blob Storage: heartbeat, mod list, world settings and versioned config backups
- Azure Virtual Machines: the Linux dedicated server, started on demand
- Managed Identity + custom RBAC + ABAC: least-privilege access for the VM and API
- Bash, systemd timers, azcopy: on-VM heartbeat, config sync and the ops agent
- Python (standard library only): the root-owned config editor and mod installer on the VM
- BepInEx: the Valheim mod loader whose logs and configs drive the mod pages
- Thunderstore API, r2modman and Gale: latest mod releases and one-click mod profiles for players
- Discord webhooks: server and operator notifications for the group
- GitHub Actions: tests,
shellcheckand deployment
Status
Live and in use by the group. Status, live world settings, the self-updating mods page with a required client list built from the server, profile codes for r2modman and Gale, config backups and config pages are all deployed, along with all five phases of Operator tools: the command queue, config editing with undo, server mod updates with automatic rollback, installing and removing mods, and Discord notifications, plus on-demand Players and Server status tabs. Next up: matching characters to SteamIDs reliably so the Players tab can show who's behind each character again.