Tag: Self-Hosting

  • Guide: Hosting Valheim server on VPS

    Guide: Hosting Valheim server on VPS

    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!