← projects
~ / projects / wts-valheim

WTS Valheim

Azure Static Web AppsAzure FunctionsNode.jsAzure Blob StorageAzure Virtual MachinesManaged IdentityBashsystemdBepInExGitHub Actions

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 what settings is the server running. Trusted players can also wake the server up themselves instead of waiting for me.

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 three pages:

  • Overview: what the server is and a quick status check
  • Mods & Setup: required client mods, install steps, and the server-side mods with the version the server is running and a link to each one's live config
  • Status: VM power state, game state and player count, refreshed automatically, plus a Start server button for operators

The design goal was to make the server self-service for players without handing anyone Azure access, and without putting any keys on the VM or in the repo.

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/mods           plugins the server actually loaded
        ├── GET  /api/config/{mod}   parsed BepInEx config for one mod
        └── POST /api/start          operator role only
              │
              │  service principal, custom role
              ▼
        Azure Resource Manager ── Valheim VM
                                     │  managed identity
                                     ▼
                              Blob Storage
                              ├── status/heartbeat.json
                              ├── status/mods.json
                              └── 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 connected means the world is loaded and joinable
  • Connections N ZDOS gives an authoritative player count, logged about every 10 minutes
  • New connection and Closing socket adjust 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.

Mods and Live Server Configs

The Mods page shows the version of each server-side mod the server actually loaded, not just what the page says should be there. 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 instead. 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, but 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.

GET /api/mods serves that file anonymously, cached for 60 seconds. It validates the document before returning it: string lengths, a cap on entries, and unknown fields dropped. Because it sits next to heartbeat.json, it needed no new permissions or settings. On the page, each table row carries its BepInEx plugin name in a data-plugin attribute, and a small script fills in a Server version column:

  • Mods listed on the page that didn't load show not loaded
  • Plugins running on the server but missing from the table are listed underneath, so the page can't quietly fall behind the real modpack

A second timer runs azcopy sync every 15 minutes to push the server's BepInEx/config folder to Blob Storage. Blob versioning keeps every previous version of every config, which gives a free backup and change history.

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. 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 mods listed in mods.js 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, connection strings) are never sent to the browser.

Letting Friends Start the Server

The VM doesn't need to run when nobody's playing. POST /api/start lets an invited operator bring it back without touching the Azure portal:

  • VM stopped or deallocated: start the VM. The game service is enabled, so it starts on boot.
  • VM running, game stopped or unknown: use Run Command to systemctl start valheimserver.
  • 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.

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 requires the operator role in the route rules, and the function checks the role again itself. Operators are invited by role with an expiring invitation.
  • 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 run commands on this one VM.
  • 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.
  • No keys on the VM: both the heartbeat and config sync authenticate with the VM's system-assigned managed identity.
  • Headers: a strict Content Security Policy (self only), 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.

Infrastructure and Delivery

  • infra/setup.sh: an idempotent script that creates the storage account and containers, enables versioning and lifecycle rules, assigns the VM's identity, defines the custom role, creates the Static Web App, and wires up the service principal and ABAC condition. It also handles secret rotation and runs cleanly from Windows Git Bash.
  • valheim-scripts/install.sh: installs azcopy, the sync and heartbeat scripts, and their systemd units on the VM.
  • GitHub Actions: every push and PR runs the API unit tests with coverage and shellcheck on all the Bash, then deploys. Pull requests get their own preview environment, which is torn down when the PR closes.

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 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 and config sync
  • BepInEx: the Valheim mod loader whose logs and configs drive the mod pages
  • GitHub Actions: tests, shellcheck and deployment

Status

Live and in use by the group. Status, the operator start button, the heartbeat, config backups, the config pages and live server mod versions on the Mods page are all deployed. Next up: adding more server-side mods to the config allowlist as the modpack grows, and listing client mods if we add any that players need to install.