Guide: Hosting Valheim server on VPS

Valheim main logo. Valheim Server guide

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!