Henlooo! This little guide is based on my latest experience, when I volunteered myself to host server of Valheim for my dear ones. I was motivated from several sides, one of them were prices for Valheim server hosting and second, I love Linux! I couldn’t slip my chance to dip my toes and try this!
So! What we gonna need?
Before Valheim server installation
Alright, here are some requirements before we start installation.
Is hosting server free?
Yes, you do not need to own the game to host the server, also you do not need steam account. We will be using SteamCMD, but it’s not necessary to put there your credentials. Only price you pay is price of server itself.
Where to get server?
There are many hosting providers out there, you can google them. I personaly have best experience with AlphaVPS, as their prices are reasonable and their staff is very nice. Also, many VPS providers have in their TOS, that they do not allow game servers on their VPS.
Do I need experience?
Not much really, if you will be following this guide and read last part about troubleshooting, I believe anyone with help of searching internet + LLM help, could make it work. Biggest pain in the ass are mods, truly.
Can I transfer to Valheim Server my existing local world?
Yes, we will get into it 🙂
VM requirements
- Public IPv4
- I recommend Debian/Ubuntu
- 2 Cores, 4 GB RAM, 16 GB free disk space
- SSH access to server
Base Installation, let’s SSH!
Install package dependencies:
sudo dpkg --add-architecture i386
sudo apt update
sudo apt install -y \
steamcmd \
lib32gcc-s1 \
lib32stdc++6 \
libatomic1 \
libpulse0 \
libpulse-dev \
unzip
Prepare dedicated user for running the sever, do not add password to user. If you are not using pub/privkey pairs to login, it will increase security of this account.
sudo adduser \
--system \
--group \
--home /srv/valheim \
valheim
Create server directory structure
sudo install -d -o valheim -g valheim /srv/valheim/server
sudo install -d -o valheim -g valheim /srv/valheim/data
sudo install -d -o valheim -g valheim /srv/valheim/data/worlds_local
sudo install -d -o valheim -g valheim /srv/valheim/backups
sudo chown -R valheim:valheim /srv/valheim/data
sudo chmod 750 /srv/valheim/data
sudo chmod 750 /srv/valheim/data/worlds_local
Test the directory structure and your user permissions. You should get “Directory is Writable“
sudo install -d -o valheim -g valheim -m 0750 /srv/valheim/server
sudo -u valheim test -w /srv/valheim/server \
&& echo "Directory is writable" \
|| echo "Directory is NOT writable"
Let’s install the valheim server!
sudo -u valheim /usr/games/steamcmd \
+@sSteamCmdForcePlatformType linux \
+force_install_dir /srv/valheim/server \
+login anonymous \
+app_update 896660 validate \
+quit
Valheim Vanilla Server installation finished
Alright, now you should have installed server. It is now in Vanilla state, no mods, no imported world.
If you want just vanilla world, without any mods you can stop here. For now, you need to open ports, first run your server and set configuration.
Open Firewall ports (Firewalld and UFW)
#For UFW
sudo ufw allow 2456/udp
sudo ufw allow 2457/udp
sudo ufw reload
#For Firewalld
sudo firewall-cmd --permanent --zone=public --add-port=2456/udp
sudo firewall-cmd --permanent --zone=public --add-port=2457/udp
sudo firewall-cmd --reload
Create simple launch script and give it executable property
sudo nano /srv/valheim/start-server.sh
#!/bin/bash
cd /srv/valheim/server || exit 1
export SteamAppId=892970
export LD_LIBRARY_PATH="./linux64:${LD_LIBRARY_PATH:-}"
exec ./valheim_server.x86_64 \
-nographics \
-batchmode \
-name "NAMEYOURSERVER" \
-port 2456 \
-world "NewWorld" \
-password "PASSWORD_TO_LOGIN" \
-savedir "/srv/valheim/data" \
-public 0
sudo chown valheim:valheim /srv/valheim/start-server.sh
sudo chmod 750 /srv/valheim/start-server.sh
Now start the server, it will take while as it will start to generate the world aaaand do bunch of stuff.
sudo -u valheim /srv/valheim/start-server.sh
You want to see “Game server connected“. Once that is done, you are finished! Create daemon to automatically start your server and keep it running. And connect to the server using your IP address and port 2456 (Like, 194.55.66.77:2456)!
sudo nano /etc/systemd/system/valheim.service
[Unit]
Description=Valheim modded dedicated server
Documentation=https://www.valheimgame.com/support/a-guide-to-dedicated-servers/
Wants=network-online.target
After=network-online.target
[Service]
Type=simple
User=valheim
Group=valheim
WorkingDirectory=/srv/valheim/server
Environment=HOME=/home/valheim
Environment=SteamAppId=892970
Environment=SteamGameId=892970
ExecStart=/bin/bash /srv/valheim/server/start-server.sh
# Valheim handles Ctrl+C/SIGINT as a graceful save and shutdown.
KillSignal=SIGINT
TimeoutStopSec=180
Restart=on-failure
RestartSec=15
LimitNOFILE=100000
UMask=0027
[Install]
WantedBy=multi-user.target
Now reload the deamons and enable and start the server.
sudo systemctl daemon-reload
sudo systemctl enable --now valheim
Now you have to wait like 10 minutes before you can log in.
I want server with mods and transfer my local game!
Now things get bit complicated, but it is manageable. If you will follow this guide step by step, you should be able to pull this off.
What to be aware of?
Most important thing is, that mods on server and on client side (your game), need to match. And that means also by the version.
If you are also importing your world, you need to save game, and import it without playing on the save. Or there will be save and world mismatch and it can cause troubles.
How to export existing world?
- Start Valheim.
- Open Manage Saves.
- Select the correct world.
- Use Move to Local if it is currently a cloud save.
- Exit Valheim completely.
- Go to: C:\Users\USERNAME\AppData\LocalLow\IronGate\Valheim\worlds_local\ (Windows)
- Go to: ~/.config/unity3d/IronGate/Valheim/worlds_local/ (Linux)
- Get: WorldName.db and WorldName.fwl
- Upload these files on your server with SFTP
How to export your mod profile
I will be honest I was doing this only with Thunderstore so if you use different mod manager, you will have to improvise a bit. For this guide to work you need to have BepInExPack downloaded.
- Open Thunderstore
- Open the profile used for this world.
- Select Settings.
- Open Profile.
- Export the profile as a file.
- Upload profile on your server with SFTP, it will be file like *.r2z or something.
Let’s assemble and install the mods!
First let’s import the Thunderstore profile
sudo install -d -o valheim -g valheim -m 0750 \
/srv/valheim/import/mods \
/srv/valheim/import/mods/profile \
/srv/valheim/import/mods/downloaded-profile
sudo install \
-o valheim \
-g valheim \
-m 0640 \
/home/user/Default_1785711977993.r2z \
/srv/valheim/import/mods/Default_1785711977993.r2z
Let’s Inspect and unpack the profile.
file /srv/valheim/import/mods/Default_1785711977993.r2z
unzip -l /srv/valheim/import/mods/Default_1785711977993.r2z |
grep -E 'manifest|profile|BepInEx|plugins|config|patchers' |
head -100
sudo rm -rf /srv/valheim/import/mods/profile
sudo install -d \
-o valheim \
-g valheim \
/srv/valheim/import/mods/profile
sudo -u valheim unzip \
/srv/valheim/import/mods/Default_1785711977993.r2z \
-d /srv/valheim/import/mods/profile
Now we need to create startup script for our server which will be using bepinex
nano /srv/valheim/server/start-modded.sh
Paste this content, do not forget to change the password and name. “world” needs to match exact name of your world save, you exported earlier.
#!/bin/bash
set -euo pipefail
cd /srv/valheim/server
exec ./start_server_bepinex.sh \
-name "Your server name" \
-port 2456 \
-world "worldname" \
-password "YourPassword" \
-savedir "/srv/valheim/data" \
-public 1
Let’s make downloader for mods. This will help you to download your mods to correct folders without worrying too much about it.
First, we will need our modlist file. Here you will be writing what mods you want installed
sudo nano /srv/valheim/mods.txt
Example content
# Core loader
denikson-BepInExPack_Valheim-5.4.2333
# Server and client mods, write mods with versions here
ValheimModding-Jotunn-2.29.2
plumga-Clutter-0.1.7
Advize-PlantEverything-1.20.0
OdinPlus-OdinHorse-1.6.5
TastyChickenLegs-NoSmokeStayLit-2.3.8
# Keep Valheim Plus absent unless both server and clients use it.
# Grantapher-ValheimPlus_Grantapher_Temporary-9.17.1
Set correct permissions
sudo chown root:valheim /srv/valheim/mods.txt
sudo chmod 640 /srv/valheim/mods.txt
Install dependencies
sudo apt update
sudo apt install -y python3 python3-yaml unzip wget rsync
Create the download python script
sudo nano /usr/local/sbin/download-valheim-mods.py
And paste these contents!
#!/usr/bin/env python3
from __future__ import annotations
import json
import re
import shutil
import sys
import tempfile
import urllib.error
import urllib.request
import zipfile
from collections import deque
from dataclasses import dataclass
from pathlib import Path, PurePosixPath
MOD_LIST = Path("/srv/valheim/mods.txt")
STAGE = Path(
"/srv/valheim/import/mods/downloaded-profile"
)
CACHE = Path(
"/srv/valheim/import/mods/package-cache"
)
LOCK_FILE = Path(
"/srv/valheim/import/mods/mods-resolved.lock"
)
METADATA_FILES = {
"icon.png",
"manifest.json",
"readme.md",
"changelog.md",
"license",
"license.md",
}
VERSION_PATTERN = re.compile(r"^\d+\.\d+\.\d+$")
@dataclass(frozen=True)
class Package:
namespace: str
name: str
version: str
@property
def identity(self) -> str:
return f"{self.namespace}-{self.name}"
@property
def dependency_string(self) -> str:
return (
f"{self.namespace}-{self.name}-{self.version}"
)
@property
def archive_name(self) -> str:
return f"{self.dependency_string}.zip"
@property
def download_url(self) -> str:
return (
"https://thunderstore.io/package/download/"
f"{self.namespace}/{self.name}/{self.version}/"
)
def parse_dependency_string(value: str) -> Package:
"""
Parse Namespace-Package-Version.
Split the version from the right because package names may
contain separators. The remaining first separator divides
namespace from package name.
"""
value = value.strip()
try:
package_identity, version = value.rsplit("-", 1)
namespace, package_name = package_identity.split("-", 1)
except ValueError as error:
raise ValueError(
"Expected Namespace-Package-Version, got: "
f"{value!r}"
) from error
if not namespace or not package_name:
raise ValueError(
f"Invalid package identity: {value!r}"
)
if not VERSION_PATTERN.fullmatch(version):
raise ValueError(
f"Invalid semantic version in {value!r}"
)
return Package(
namespace=namespace,
name=package_name,
version=version,
)
def read_requested_packages() -> list[Package]:
if not MOD_LIST.is_file():
raise FileNotFoundError(
f"Mod list not found: {MOD_LIST}"
)
packages: list[Package] = []
with MOD_LIST.open("r", encoding="utf-8") as file:
for line_number, raw_line in enumerate(file, start=1):
# Allow comments after an entry as well as full-line
# comments.
value = raw_line.split("#", 1)[0].strip()
if not value:
continue
try:
packages.append(
parse_dependency_string(value)
)
except ValueError as error:
raise ValueError(
f"{MOD_LIST}:{line_number}: {error}"
) from error
if not packages:
raise RuntimeError(
f"No packages found in {MOD_LIST}"
)
return packages
def download(package: Package, destination: Path) -> None:
destination.parent.mkdir(
parents=True,
exist_ok=True,
)
partial = destination.with_suffix(
destination.suffix + ".partial"
)
partial.unlink(missing_ok=True)
request = urllib.request.Request(
package.download_url,
headers={
"User-Agent": (
"Darkpost-Valheim-Mod-Downloader/2.0"
)
},
)
print(f" Downloading {package.download_url}")
try:
with urllib.request.urlopen(
request,
timeout=180,
) as response:
with partial.open("wb") as output:
shutil.copyfileobj(response, output)
# Test before adding the file to the persistent cache.
with zipfile.ZipFile(partial) as archive:
bad_member = archive.testzip()
if bad_member is not None:
raise RuntimeError(
"Corrupt ZIP member: "
f"{bad_member}"
)
partial.replace(destination)
except urllib.error.HTTPError as error:
partial.unlink(missing_ok=True)
raise RuntimeError(
f"Thunderstore returned HTTP {error.code} for "
f"{package.dependency_string}"
) from error
except Exception:
partial.unlink(missing_ok=True)
raise
def get_archive(package: Package) -> Path:
archive = CACHE / package.archive_name
if archive.is_file():
try:
with zipfile.ZipFile(archive) as zip_file:
bad_member = zip_file.testzip()
if bad_member is None:
print(" Using cached archive")
return archive
print(
" Cached archive is corrupt; "
"downloading again"
)
except zipfile.BadZipFile:
print(
" Cached archive is not a valid ZIP; "
"downloading again"
)
archive.unlink(missing_ok=True)
download(package, archive)
return archive
def safe_extract(
archive_path: Path,
destination: Path,
) -> None:
"""
Extract a ZIP while rejecting absolute paths and '..'.
"""
destination.mkdir(
parents=True,
exist_ok=True,
)
with zipfile.ZipFile(archive_path) as archive:
for member in archive.infolist():
member_path = PurePosixPath(member.filename)
if member_path.is_absolute():
raise RuntimeError(
f"Unsafe absolute ZIP path: "
f"{member.filename}"
)
if ".." in member_path.parts:
raise RuntimeError(
f"Unsafe parent path in ZIP: "
f"{member.filename}"
)
archive.extractall(destination)
def find_manifest(extracted: Path) -> Path:
manifests = [
path
for path in extracted.rglob("manifest.json")
if path.is_file()
]
if not manifests:
raise RuntimeError(
"Package does not contain manifest.json"
)
# Thunderstore packages normally place it at archive root.
manifests.sort(
key=lambda path: (
len(path.relative_to(extracted).parts),
str(path),
)
)
return manifests[0]
def load_manifest(
extracted: Path,
package: Package,
) -> dict:
manifest_path = find_manifest(extracted)
try:
with manifest_path.open(
"r",
encoding="utf-8-sig",
) as file:
manifest = json.load(file)
except (OSError, json.JSONDecodeError) as error:
raise RuntimeError(
f"Cannot read manifest for "
f"{package.dependency_string}: {error}"
) from error
dependencies = manifest.get("dependencies", [])
if not isinstance(dependencies, list):
raise RuntimeError(
f"Invalid dependencies field in "
f"{package.dependency_string}"
)
return manifest
def copy_item(
source: Path,
destination: Path,
) -> None:
destination.parent.mkdir(
parents=True,
exist_ok=True,
)
if source.is_dir():
shutil.copytree(
source,
destination,
dirs_exist_ok=True,
)
else:
shutil.copy2(source, destination)
def copy_contents(
source: Path,
destination: Path,
) -> None:
destination.mkdir(
parents=True,
exist_ok=True,
)
for item in source.iterdir():
copy_item(
item,
destination / item.name,
)
def install_package(
extracted: Path,
package: Package,
) -> None:
if package.name == "BepInExPack_Valheim":
startup_scripts = list(
extracted.rglob("start_server_bepinex.sh")
)
if not startup_scripts:
raise RuntimeError(
"BepInExPack_Valheim does not contain "
"start_server_bepinex.sh"
)
# The startup script should be in the directory that
# represents the Valheim installation root.
package_root = startup_scripts[0].parent
copy_contents(package_root, STAGE)
return
handled_layout = False
# Normal layout:
#
# BepInEx/plugins
# BepInEx/config
# BepInEx/patchers
for bepinex_directory in extracted.rglob("BepInEx"):
if not bepinex_directory.is_dir():
continue
copy_contents(
bepinex_directory,
STAGE / "BepInEx",
)
handled_layout = True
# Some packages start directly with plugins/, config/, etc.
for directory_name in (
"plugins",
"patchers",
"config",
"core",
"monomod",
):
for directory in extracted.rglob(directory_name):
if not directory.is_dir():
continue
if "BepInEx" in directory.parts:
continue
copy_contents(
directory,
STAGE / "BepInEx" / directory_name,
)
handled_layout = True
# Loose-package fallback. Keep the complete package together,
# including asset bundles, translations and supporting data.
if not handled_layout:
destination = (
STAGE
/ "BepInEx"
/ "plugins"
/ package.identity
)
destination.mkdir(
parents=True,
exist_ok=True,
)
for item in extracted.iterdir():
if item.name.lower() in METADATA_FILES:
continue
copy_item(
item,
destination / item.name,
)
def resolve_and_install(
requested: list[Package],
) -> dict[str, Package]:
queue: deque[Package] = deque(requested)
resolved: dict[str, Package] = {}
installed_order: list[Package] = []
while queue:
package = queue.popleft()
existing = resolved.get(package.identity)
if existing is not None:
if existing.version != package.version:
raise RuntimeError(
"Dependency version conflict:\n"
f" {existing.dependency_string}\n"
f" {package.dependency_string}\n"
"Choose one compatible version explicitly."
)
continue
print(
f"[{len(resolved) + 1}] "
f"{package.dependency_string}"
)
archive = get_archive(package)
with tempfile.TemporaryDirectory(
prefix="valheim-mod-"
) as temporary_directory:
extracted = Path(temporary_directory)
safe_extract(archive, extracted)
manifest = load_manifest(
extracted,
package,
)
manifest_name = manifest.get("name")
manifest_version = manifest.get("version_number")
if (
manifest_version
and manifest_version != package.version
):
raise RuntimeError(
"Downloaded package version mismatch: "
f"requested {package.version}, "
f"manifest contains {manifest_version}"
)
print(
f" Package manifest: "
f"{manifest_name or package.name} "
f"{manifest_version or package.version}"
)
dependencies = manifest.get(
"dependencies",
[],
)
for dependency_value in dependencies:
dependency = parse_dependency_string(
dependency_value
)
print(
" Requires: "
f"{dependency.dependency_string}"
)
queue.append(dependency)
install_package(
extracted,
package,
)
resolved[package.identity] = package
installed_order.append(package)
return resolved
def write_lock_file(
resolved: dict[str, Package],
) -> None:
LOCK_FILE.parent.mkdir(
parents=True,
exist_ok=True,
)
temporary = LOCK_FILE.with_suffix(".lock.partial")
with temporary.open(
"w",
encoding="utf-8",
) as file:
file.write(
"# Resolved Valheim Thunderstore packages\n"
)
file.write(
"# Generated automatically; do not edit.\n\n"
)
for package in sorted(
resolved.values(),
key=lambda item: item.identity.lower(),
):
file.write(
package.dependency_string + "\n"
)
temporary.replace(LOCK_FILE)
def main() -> int:
try:
requested = read_requested_packages()
print(f"Mod list: {MOD_LIST}")
print(f"Requested root packages: {len(requested)}")
print()
shutil.rmtree(
STAGE,
ignore_errors=True,
)
STAGE.mkdir(
parents=True,
exist_ok=True,
)
CACHE.mkdir(
parents=True,
exist_ok=True,
)
resolved = resolve_and_install(requested)
write_lock_file(resolved)
dll_directory = STAGE / "BepInEx"
dll_files = (
list(dll_directory.rglob("*.dll"))
if dll_directory.exists()
else []
)
print()
print("Staging completed successfully.")
print(f"Requested packages: {len(requested)}")
print(f"Resolved packages: {len(resolved)}")
print(f"DLL files found: {len(dll_files)}")
print(f"Staging directory: {STAGE}")
print(f"Resolved lock file: {LOCK_FILE}")
return 0
except KeyboardInterrupt:
print("\nInterrupted.", file=sys.stderr)
return 130
except Exception as error:
print(
f"ERROR: {error}",
file=sys.stderr,
)
return 1
if __name__ == "__main__":
raise SystemExit(main())
Set permissions once more
sudo chown root:root /usr/local/sbin/download-valheim-mods.py
sudo chmod 750 /usr/local/sbin/download-valheim-mods.py
Download the mods to staging
sudo /usr/local/sbin/download-valheim-mods.py
You should see something like this. Now the mods are downloaded to staging folder. They are not yet on your server. That will be next step.
Mod list: /srv/valheim/mods.txt
Requested root packages: 6
[1] denikson-BepInExPack_Valheim-5.4.2333
Downloading ...
Package manifest: BepInExPack_Valheim 5.4.2333
[2] ValheimModding-Jotunn-2.29.2
Downloading ...
...
Staging completed successfully.
Requested packages: 6
Resolved packages: 6
DLL files found: ...
Staging directory: /srv/valheim/import/mods/downloaded-profile
Resolved lock file: /srv/valheim/import/mods/mods-resolved.lock
Move mods from staging to production
sudo rsync -a \
/srv/valheim/import/mods/downloaded-profile/ \
/srv/valheim/server/
sudo chown -R valheim:valheim /srv/valheim/server
sudo chmod 750 \
/srv/valheim/server/start_server_bepinex.sh \
/srv/valheim/server/start-modded.sh
And now the map
To install your map you played on in your local game, place .db and .fwl files into this location
sudo install \
-o valheim \
-g valheim \
-m 0640 \
/home/user/worlds_local/worldname.db \
/srv/valheim/data/worlds_local/worldname.db
sudo install \
-o valheim \
-g valheim \
-m 0640 \
/home/user/worlds_local/worldname.fwl \
/srv/valheim/data/worlds_local/worldname.fwl
Now let’s verify that our user can write to those files!
sudo -u valheim test -r \
/srv/valheim/data/worlds_local/SiiskusLefLeg.db &&
echo "DB readable"
sudo -u valheim test -w \
/srv/valheim/data/worlds_local/SiiskusLefLeg.db &&
echo "DB writable"
sudo -u valheim test -r \
/srv/valheim/data/worlds_local/SiiskusLefLeg.fwl &&
echo "FWL readable"
sudo -u valheim test -w \
/srv/valheim/data/worlds_local/SiiskusLefLeg.fwl &&
echo "FWL writable"
Let’s create daemon and start the server
nano /etc/systemd/system/valheim.service
[Unit]
Description=Valheim modded dedicated server
Wants=network-online.target
After=network-online.target
[Service]
Type=simple
User=valheim
Group=valheim
WorkingDirectory=/srv/valheim/server
ExecStart=/srv/valheim/server/start-modded.sh
Restart=on-failure
RestartSec=10
KillSignal=SIGINT
TimeoutStopSec=180
StandardOutput=journal
StandardError=journal
# Basic limits
LimitNOFILE=100000
[Install]
WantedBy=multi-user.target
sudo chown valheim:valheim \
/srv/valheim/server/start-modded.sh \
/srv/valheim/server/start_server_bepinex.sh
sudo chmod 750 \
/srv/valheim/server/start-modded.sh \
/srv/valheim/server/start_server_bepinex.sh
sudo systemctl daemon-reload
sudo systemctl enable --now valheim
How to update mods?
To update mods, just change versions in mods.txt and rerun the command for downloading mods. And then deploy. You can bundle it in this script.
nano /usr/local/sbin/update-valheim-mods
Paste content
#!/usr/bin/env bash
set -Eeuo pipefail
SERVICE="valheim"
VALHEIM_ROOT="/srv/valheim"
SERVER_DIR="${VALHEIM_ROOT}/server"
DATA_DIR="${VALHEIM_ROOT}/data"
BACKUP_DIR="${VALHEIM_ROOT}/backups"
MOD_LIST="${VALHEIM_ROOT}/mods.txt"
DOWNLOADER="/usr/local/sbin/download-valheim-mods.py"
STAGE_DIR="${VALHEIM_ROOT}/import/mods/downloaded-profile"
STAGE_BEPINEX="${STAGE_DIR}/BepInEx"
ACTIVE_BEPINEX="${SERVER_DIR}/BepInEx"
ACTIVE_PLUGINS="${ACTIVE_BEPINEX}/plugins"
ACTIVE_CONFIG="${ACTIVE_BEPINEX}/config"
STAMP="$(date +%F-%H%M%S)"
BACKUP_FILE="${BACKUP_DIR}/pre-mod-update-${STAMP}.tar.gz"
OLD_PLUGINS="${SERVER_DIR}/BepInEx/plugins.before-${STAMP}"
SERVER_WAS_ACTIVE=0
DEPLOYMENT_STARTED=0
log() {
printf '[%s] %s\n' "$(date '+%F %T')" "$*"
}
die() {
printf '[%s] ERROR: %s\n' "$(date '+%F %T')" "$*" >&2
exit 1
}
rollback() {
local exit_code=$?
if [[ "$DEPLOYMENT_STARTED" -ne 1 ]]; then
exit "$exit_code"
fi
log "Update failed. Attempting rollback."
systemctl stop "$SERVICE" 2>/dev/null || true
if [[ -f "$BACKUP_FILE" ]]; then
log "Restoring BepInEx and world backup."
rm -rf "$ACTIVE_BEPINEX"
tar -C "$VALHEIM_ROOT" -xzf "$BACKUP_FILE" || {
printf 'Rollback extraction failed: %s\n' \
"$BACKUP_FILE" >&2
exit "$exit_code"
}
chown -R valheim:valheim \
"$SERVER_DIR" \
"$DATA_DIR"
elif [[ -d "$OLD_PLUGINS" ]]; then
log "Restoring previous plugin directory."
rm -rf "$ACTIVE_PLUGINS"
mv "$OLD_PLUGINS" "$ACTIVE_PLUGINS"
chown -R valheim:valheim "$ACTIVE_PLUGINS"
fi
if [[ "$SERVER_WAS_ACTIVE" -eq 1 ]]; then
log "Starting rolled-back server."
systemctl start "$SERVICE" || true
fi
printf '\nUpdate failed. Backup retained at:\n%s\n' \
"$BACKUP_FILE" >&2
exit "$exit_code"
}
trap rollback ERR
require_root() {
if [[ "$EUID" -ne 0 ]]; then
die "Run this script as root or with sudo."
fi
}
check_requirements() {
local command_name
for command_name in \
systemctl \
tar \
rsync \
find \
grep \
sed
do
command -v "$command_name" >/dev/null 2>&1 ||
die "Required command not found: $command_name"
done
[[ -x "$DOWNLOADER" ]] ||
die "Downloader is missing or not executable: $DOWNLOADER"
[[ -f "$MOD_LIST" ]] ||
die "Mod list not found: $MOD_LIST"
[[ -d "$SERVER_DIR" ]] ||
die "Server directory not found: $SERVER_DIR"
[[ -d "${DATA_DIR}/worlds_local" ]] ||
die "World directory not found: ${DATA_DIR}/worlds_local"
install -d \
-o valheim \
-g valheim \
-m 0750 \
"$BACKUP_DIR"
}
show_requested_mods() {
log "Requested root packages:"
grep -Ev '^[[:space:]]*(#|$)' "$MOD_LIST" |
sed 's/^/ /'
}
build_staging() {
log "Downloading mods and resolving dependencies."
"$DOWNLOADER"
[[ -d "$STAGE_BEPINEX" ]] ||
die "Downloader did not create: $STAGE_BEPINEX"
[[ -d "${STAGE_BEPINEX}/plugins" ]] ||
die "Staged plugin directory is missing."
local dll_count
dll_count="$(
find "$STAGE_BEPINEX" \
-type f \
-iname '*.dll' |
wc -l
)"
[[ "$dll_count" -gt 0 ]] ||
die "No DLL files were found in staging."
log "Staging contains ${dll_count} DLL files."
}
stop_server() {
if systemctl is-active --quiet "$SERVICE"; then
SERVER_WAS_ACTIVE=1
log "Stopping ${SERVICE}.service."
systemctl stop "$SERVICE"
else
log "${SERVICE}.service is already stopped."
fi
if systemctl is-active --quiet "$SERVICE"; then
die "Valheim service did not stop."
fi
}
create_backup() {
log "Creating backup: $BACKUP_FILE"
local paths=()
[[ -d "$ACTIVE_BEPINEX" ]] &&
paths+=("server/BepInEx")
[[ -d "${DATA_DIR}/worlds_local" ]] &&
paths+=("data/worlds_local")
[[ -f "$MOD_LIST" ]] &&
paths+=("mods.txt")
[[ "${#paths[@]}" -gt 0 ]] ||
die "Nothing was found to back up."
tar -C "$VALHEIM_ROOT" \
-czf "$BACKUP_FILE" \
"${paths[@]}"
test -s "$BACKUP_FILE" ||
die "Backup file is empty."
}
deploy_plugins() {
DEPLOYMENT_STARTED=1
if [[ -d "$ACTIVE_PLUGINS" ]]; then
log "Moving current plugins to:"
log "$OLD_PLUGINS"
mv "$ACTIVE_PLUGINS" "$OLD_PLUGINS"
fi
install -d \
-o valheim \
-g valheim \
-m 0750 \
"$ACTIVE_PLUGINS"
log "Deploying fresh plugin set."
rsync -a \
"${STAGE_BEPINEX}/plugins/" \
"${ACTIVE_PLUGINS}/"
}
deploy_supporting_files() {
log "Updating BepInEx loader and supporting files."
rsync -a \
--exclude='BepInEx/plugins/' \
--exclude='BepInEx/config/' \
"${STAGE_DIR}/" \
"${SERVER_DIR}/"
if [[ -d "${STAGE_BEPINEX}/config" ]]; then
log "Adding new configuration files without overwriting existing settings."
install -d \
-o valheim \
-g valheim \
-m 0750 \
"$ACTIVE_CONFIG"
rsync -a \
--ignore-existing \
"${STAGE_BEPINEX}/config/" \
"${ACTIVE_CONFIG}/"
fi
}
fix_permissions() {
log "Fixing ownership and executable permissions."
chown -R valheim:valheim "$SERVER_DIR"
find "$SERVER_DIR" \
-type d \
-exec chmod 0750 {} +
find "$SERVER_DIR" \
-type f \
-name '*.sh' \
-exec chmod 0750 {} +
if [[ -f "${SERVER_DIR}/valheim_server.x86_64" ]]; then
chmod 0750 "${SERVER_DIR}/valheim_server.x86_64"
fi
}
start_and_verify() {
log "Starting ${SERVICE}.service."
systemctl start "$SERVICE"
sleep 15
if ! systemctl is-active --quiet "$SERVICE"; then
systemctl status "$SERVICE" \
--no-pager \
-l || true
die "Valheim service failed to remain active."
fi
local start_time
local journal
start_time="$(
systemctl show "$SERVICE" \
-p ExecMainStartTimestamp \
--value
)"
journal="$(
journalctl \
-u "$SERVICE" \
--since "$start_time" \
--no-pager
)"
if grep -Eqi \
'missing dependency|mod version mismatch|failed to load|fatal|unauthorizedaccessexception' \
<<<"$journal"
then
printf '%s\n' "$journal" |
grep -Ei -B5 -A15 \
'missing dependency|mod version mismatch|failed to load|fatal|unauthorizedaccessexception' \
>&2 || true
die "A critical error was detected in the startup log."
fi
log "Service is active."
printf '\nLoaded BepInEx plugins:\n'
if [[ -f "${ACTIVE_BEPINEX}/LogOutput.log" ]]; then
grep 'Loading \[' \
"${ACTIVE_BEPINEX}/LogOutput.log" |
sed -E 's/^.*Loading \[([^]]+)\].*$/ \1/' ||
true
else
printf ' BepInEx log has not been created yet.\n'
fi
printf '\nRelevant server startup lines:\n'
printf '%s\n' "$journal" |
grep -Ei \
'setting -savedir|load world:|loading [0-9]+ zdos|game server connected|opened steam server|chainloader startup complete' ||
true
}
remove_old_plugin_backup() {
if [[ -d "$OLD_PLUGINS" ]]; then
log "Previous plugins retained for rollback:"
log "$OLD_PLUGINS"
fi
}
main() {
require_root
check_requirements
show_requested_mods
build_staging
stop_server
create_backup
deploy_plugins
deploy_supporting_files
fix_permissions
start_and_verify
remove_old_plugin_backup
DEPLOYMENT_STARTED=0
trap - ERR
printf '\nMod update completed successfully.\n'
printf 'Backup: %s\n' "$BACKUP_FILE"
}
main "$@"
Set permissions
sudo chown root:root /usr/local/sbin/update-valheim-mods
sudo chmod 750 /usr/local/sbin/update-valheim-mods
EXECUTE!
sudo /usr/local/sbin/update-valheim-mods
Server is not working, I cannot connect?
During the process a lot of things can mess up, do not feel bad for it. Luckily the server itself is rather talkative. You can inpect what is going on by running launch scripts manualy, or check logs of the service itself.
sudo journalctl -u valheim -f
This is stage, where any LLM is extremely helpful and may help you to debug the whole mess. I spent two days on making this work and it was fun experience and when my friends finally landed on server and I saw them playing, my heart got warmer exactly by 3 degrees.
Anyhow! I wish you luck, and if you run into any problems, leave comment, I will try to help!
