Back to the blog

How to Run a Dedicated Valheim Server on TrueNAS

Valheim left Early Access on September 9th. Five and a half years later, Iron Gate shipped 1.0 with the Deep North, the eighth and final biome, plus an achievement system, a pile of new weapons and build pieces, and full crossplay across PC, Xbox, PS5, and Switch 2.

I'm genuinely excited about this one, and the thing I wanted most on launch week was a world that's always up, so nobody has to wait on a host to log in. Here's the whole setup on TrueNAS, including the handful of things that tripped me up along the way.

What you'll end up with

A Valheim dedicated server running as a Docker app on TrueNAS, with the world on its own ZFS dataset, hourly in-container backups, automatic updates, crossplay on so console friends can join, and a stable hostname backed by dynamic DNS so nobody has to chase a rotating join code.

Budget about an hour, most of which is waiting on downloads.

Before you start

  • TrueNAS on a recent release. Since Electric Eel, apps run on plain Docker instead of the old integrated Kubernetes cluster, and the Apps screen has an Install via YAML option that accepts a Docker Compose file. That's what this guide uses.
  • Free space on a pool. The server binaries are a little over 2GB; the world starts small and grows.
  • RAM to spare. 4GB is comfortable for a fresh world and a small group. Memory scales with how much of the world has been built and explored, not with player count, so an old sprawling world wants 6 to 8GB.
  • A domain on Cloudflare, if you want the stable-hostname part. Optional but worth it.
  • No copy of Valheim required. The dedicated server is a separate, free Steam app.

Step 1: Create the dataset and directories

In Datasets, create a dataset for the server, something like apps/valheim on your pool. Making it a dataset rather than a plain folder is the whole point: it lets you snapshot and replicate the world independently of everything else.

I went one level deeper and used apps/valheim/fall-2026, so a future world gets its own sibling directory instead of tangling with this one.

Then create the two directories the container mounts. Do this before you install the app. TrueNAS will not create them for you, and a missing host path fails the deploy:

text
mkdir -p /mnt/tank/apps/valheim/fall-2026/{config,server}

Substitute your own pool name for tank throughout.

The split matters. /config is the part you actually care about, and losing it means losing the world. /server is just the files SteamCMD pulls down; mounting it persistently only saves re-downloading them every time the container is recreated.

Step 2: Install the app from YAML

Go to Apps โ†’ Discover Apps โ†’ Custom App, open the three-dot menu, and choose Install via YAML.

Before you get to the editor, TrueNAS asks for an Application Name, and it validates that field against ^[a-z]([-a-z0-9]*[a-z0-9])?$. Lowercase letters, digits, and hyphens; must start with a letter and end with a letter or digit. valheim-fall-2026 is fine. Valheim, valheim_2026, and 2026-valheim all get rejected with a regex in the error message and no explanation.

Then paste this:

yml
services:
  valheim:
    image: ghcr.io/lloesche/valheim-server:latest
    restart: unless-stopped
    cap_add:
      - sys_nice
    stop_grace_period: 2m
    ports:
      - "2456-2458:2456-2458/udp"
    volumes:
      - /mnt/tank/apps/valheim/fall-2026/config:/config
      - /mnt/tank/apps/valheim/fall-2026/server:/opt/valheim
    environment:
      SERVER_NAME: "My Valheim Server"
      WORLD_NAME: "MyWorld"
      SERVER_PASS: "change-me-min-5-chars"
      SERVER_PUBLIC: "false"
      CROSSPLAY: "true"
      TZ: "America/Denver"
      UPDATE_CRON: "0 5 * * *"
      BACKUPS_CRON: "0 * * * *"
      BACKUPS_MAX_COUNT: "48"

Change: the two volume paths, SERVER_NAME, WORLD_NAME, SERVER_PASS, and TZ.

Leave alone, and why:

  • stop_grace_period: 2m. Valheim flushes the world to disk on shutdown, and that isn't instant on a large world. Docker's ten-second default will SIGKILL the server mid-write. Two minutes is cheap insurance against a corrupted save. Note that the guided Custom App wizard has no field for this, which is the main reason to use the YAML editor instead.
  • cap_add: sys_nice. Optional, but without it the Steam libraries can't raise their own thread priority and you'll see a warning on every start.
  • UPDATE_CRON. Clients update through Steam the moment Iron Gate ships a patch; servers don't. A version mismatch means nobody can connect, and the error won't tell you that. This checks nightly and restarts only if there's actually something to apply.
  • No container_name. TrueNAS names the container off the app name, and hardcoding it collides if you stand up a second instance later.
  • No RESTART_CRON. See the join code section below. An unconditional nightly restart has a cost you probably don't want.

Two rules on the values themselves:

SERVER_PASS must be at least five characters and must not appear inside the world name. This is the single most common reason a Valheim server refuses to start, and the failure looks like a generic crash loop. Don't commit the real one; pull it from a secret or an env file.

Keep WORLD_NAME to letters, digits, and hyphens. Under 1.0 the world name becomes a directory name inside worlds_local/, and it also gets passed through to the -world flag. I started with something containing a space and an ampersand, thought better of it, and changed it before first boot. If a shell-hostile world name does break, the failure mode is the server silently generating a fresh empty world rather than erroring, which is a miserable thing to discover a week in. Rename before the world exists, not after.

Step 3: Ports, forwarding, and the relay

Valheim uses the configured UDP port and the one after it: 2456 for the game, 2457 for Steam queries. Crossplay adds 2458.

What crossplay actually does is swap the networking backend entirely. Iron Gate's framing is that the flag runs the server on the PlayFab backend so users from any platform can join, and without it the Steam backend is used. Relayed connectivity, server advertising, and multi-platform identities all run on Microsoft Azure PlayFab Party. Consoles can't reach Steam's networking at all, which is why this is all-or-nothing rather than a per-player toggle.

The practical consequence, and the reason this section is short:

With crossplay on, you forward nothing. The IP and port are used only as identifiers for a relay; no port binding is made on the server locally. There is no inbound listener to forward traffic to. This also means it works fine behind carrier-grade NAT, where direct hosting would be impossible.

With crossplay off, forward UDP 2456 and 2457 to your TrueNAS box's LAN IP. TCP isn't used.

The trade-off for the relay path: because there are no inbound listening ports, players can't connect using a loopback or LAN address. You cannot test your own server from your own network. Have someone outside the house verify it.

I'd leave crossplay on. It's the headline feature of 1.0 for a mixed group, and it's the only way PS5 and Switch 2 players get into the same world as everyone on Steam.

Step 4: Watch the first boot, and find the real join code

Nothing appears to happen for several minutes on first start. The container is downloading the dedicated server from Steam before it launches anything. Follow the logs from the TrueNAS shell:

text
docker logs -f valheim

Here's the part that isn't in other guides. A healthy crossplay boot looks like this:

text
Register PlayFab server "My Server" with IP 203.0.113.10:2456
Joined PlayFab Party network with ID "..."
Created PlayFab lobby with ID "..."
Session "My Server" registered with join code 690439
Created new join code 557700 for session "My Server"
Created new join code 768056 for session "My Server"
Retry join-code check 99
Retry join-code check 98
Session "My Server" with join code 768056 and IP 203.0.113.10:2456 is active with 0 player(s)

The code is issued three times in under a second, and the first one is wrong. Every guide I found points you at the registered with join code line, which is stale by the time the server finishes booting. The authoritative value is on the last line, the one ending is active with N player(s). I'd guess the regeneration is collision avoidance; six digits isn't a large space and PlayFab is handing codes to everyone. Either way, if you script this, grep for is active, not registered with.

A Update PlayFab entity token line every few minutes afterward is routine auth refresh, not a problem.

The code also rotates on every boot, including scheduled restarts and update restarts. That's why the compose file above has no RESTART_CRON. An unconditional nightly restart means handing your group a new six-digit code every morning, for no benefit. UPDATE_CRON alone restarts only when there's a patch, which is rare enough to live with.

Step 5: Connect

With crossplay off, use Join IP in the server browser with your address and port, then the password.

With crossplay on there are two routes, and they aren't equivalent:

  • Console players are join-code only. The console builds offer the code route rather than an IP field. PS5, Switch 2, and Xbox friends need the six digits, and they need fresh ones after any restart.
  • PC players can use an address instead. Players may supply the external IPv4 address and gameplay port, and Valheim looks up which relay that pair is registered to. A domain name works in place of the IP, as long as it resolves to an A record of the exact external address the relay sees.

That second route is the interesting one, and it's what the next step is about.

Step 6: Give PC players a stable hostname

The join code churns. Your public IP churns too, if you're on residential cable. Dynamic DNS fixes the second problem and, as a side effect, makes the first one irrelevant for anyone on PC.

Create a scoped Cloudflare token

In the Cloudflare dashboard, go to My Profile โ†’ API Tokens โ†’ Create Token, and use the Edit zone DNS template. Scope it to the single zone you're using. Don't reach for the Global API Key, which is account-wide and can't be revoked without breaking everything else.

While you're in the dashboard, grab the Zone ID from the domain's Overview page. You'll need both.

Install DDNS-Updater

TrueNAS has DDNS-Updater in the community train, a lightweight universal updater with a web UI, wrapping qdm12/ddns-updater. Install it from the catalog rather than the custom-app route; the guided form here is fine and already knows about Cloudflare.

The fields that matter:

  • Provider: Cloudflare
  • Domain: valheim.example.com (the full hostname, not just the apex)
  • IP Version: IPv4
  • Zone ID: from the domain Overview page
  • Token: the scoped token you just made
  • TTL: 1 means automatic. I'd set an explicit 300 instead. On automatic, Cloudflare can hand out a longer TTL than you want, and the whole point is fast propagation when your IP moves.
  • Proxied: false. This one is load-bearing. A proxied record resolves to Cloudflare's anycast IPs rather than your origin, so the relay lookup won't match what PlayFab registered and connections fail. Cloudflare's proxy only handles HTTP and HTTPS anyway, so it was never going to carry game traffic.

Leave Update Period at 5m and the public-IP provider lists at their defaults.

The container runs as uid/gid 568 (the apps user), so give it a small dataset for its config and let TrueNAS handle ownership.

The gotcha

If you already run DDNS-Updater for other hostnames, you have to add this one to its config list. Creating the A record by hand in the Cloudflare dashboard gets you a one-time snapshot of today's IP. It'll work perfectly until your ISP reassigns you, then break silently. Friends get a connection error and your server logs show nothing wrong, because nothing is wrong on the server. This is the single most likely thing to bite you a month later.

Handing it out

PC players enter the hostname in Join IP. The port is optional if you're on the default, since the client fills in 2456, so valheim.example.com alone works. Include the explicit :2456 anyway if you ever plan to run a second world on another port, because a bare hostname will quietly resolve to whichever server registered that IP and port pair.

Step 7: Import an existing world (optional)

This is where 1.0 breaks every guide written before September. Valheim used to store a world as a pair of files, <World>.db and <World>.fwl. As of 1.0 a world is a directory inside worlds_local/, holding the metadata and the world data split into chunks.

Back up the source world first. On a PC, worlds live in %USERPROFILE%\AppData\LocalLow\IronGate\Valheim\ on Windows and ~/.config/unity3d/IronGate/Valheim/ on Linux.

Then, depending on what you're migrating:

  • A pre-1.0 world: copy both <World>.db and <World>.fwl into config/worlds_local/ and set WORLD_NAME to the filename without the extension. The server converts it on first load, which takes several minutes and looks a lot like a hang. Let it finish.
  • A world already on 1.0: copy the entire directory across and set WORLD_NAME to the directory name. Make sure nothing was left out of the archive.

Restart the app after copying. A world from Game Pass or console can't be moved this way, since those saves live in an opaque container format with no supported export path.

Step 8: Add ZFS snapshots

The container's hourly backups into /config/backups cover the "someone blew up the base" class of problem. The better layer sits underneath.

In Data Protection โ†’ Periodic Snapshot Tasks, add a task against the dataset. Because /config lives on its own dataset, a snapshot captures the world, the container's backups, and the permission lists in one atomic operation, and it replicates off-box with everything else you already back up.

This is the actual argument for running a game server on a NAS instead of a spare mini PC: the storage layer was already solving this problem.

Troubleshooting

App name rejected with a regex. Lowercase letters, digits, hyphens; starts with a letter, ends alphanumeric.

Deploy fails on storage. The host directories don't exist yet. mkdir -p them first.

Container restarts in a loop, no obvious error. Almost always the password: under five characters, or contained in the world name.

Server generated a brand new world instead of loading mine. WORLD_NAME doesn't match the directory name (1.0) or filename base (pre-1.0). Check capitalization, and check for characters that don't survive a shell.

World loads but all the builds are gone. On a pre-1.0 import, you copied the .fwl but not the .db. The .fwl holds the seed, so the terrain regenerates correctly and nothing else does. Copy both.

The join code doesn't work. Either the server restarted since you shared it, or you read it off the registered with join code line instead of the is active line.

Hostname worked for weeks, then stopped. Your IP changed and the record isn't actually in the DDNS updater's config, or the record got flipped to proxied.

Can't connect from inside your own house. Expected on crossplay. Test from outside.

Nobody can connect after a patch. Server is on an older build than the clients. Restart it; UPDATE_CRON handles it going forward.

Mods aren't working. Iron Gate ran no public test branch for 1.0, so BepInEx and everything built on it only started catching up after launch day. Plan on vanilla for a while. There's still no Steam Workshop, so mods come from Thunderstore or Nexus and get placed by hand.

That's it

An hour of setup and the world is just there. No host, no scheduling, no "can you hop on real quick so I can log in." Sailing north into the Deep North knowing the server will still be running tomorrow is exactly the kind of small, unglamorous infrastructure win that makes keeping a home lab worthwhile.

See you in the snow.

As always, thank you for reading. I really appreciate it. ๐Ÿ’–