Tag: Guide

  • 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!

  • Shadowrun 5: The Grinman (Toxic Shinto Shaman)

    Shadowrun 5: The Grinman (Toxic Shinto Shaman)

    Okay, so I was thinking about writing some villain. And this idea came to my mind. Toxic Shinto Shaman. I never wrote toxic magicians, so this is my firs one, bear with me.

    Urban legend of The Grinman

    Nobody know his original name, in bustling Neo-Tokyo is very easy to lose your name, even your face. Legends say, that he used to be Shinto shaman, taking care one of the many shrines in Neo-Tokyo.

    He was kind-hearted, humble and gods favored him. But, darkness was circling around his wife, and he wasn’t able to stop it. He failed to notice change in her behavior. One day, when he came to their humble home, he had strange feeling something terrible happened.

    His beloved wife, haunted by dark thoughts jumped under the train. Crushed by sorrow, humble priest descended into the the madness.

    It is said that he now wanders the city’s forgotten stations, maintenance shafts and flooded tunnels. He summons vile spirits, sends them to haunt people. Other say, that with different faces he waits on Railway stations porches and with power of suggestion, lures poor souls to jump under the trains. Where he appears accident happens, and most likely tragic.

    Many claim, that his most favorite spirit are The Hands. Haunting stories tell tales, those hands are hands of his deceased wife. That he corrupted her innocent spirit and now calls her hands to do his biding and torment people.

    Rumors

    • “If a smiling man asks whether you hear the train, don’t answer.”
    • “People who see wet handprints near the tunnel walls should leave immediately.”
    • “Construction deaths spike whenever someone reports laughter in sealed shafts.”
    • “The Hands don’t kill first. They herd.”
    • “He can’t make you jump if you never meet his eyes.”
    • “He stands where no train stops anymore.”

    Stats

    The Grinman

    The grinning japanesese man, toxic shaman

    Substats

    • Initiative: 9 + 1D6 (Physical); 10 + 2D6 (Astral)
    • Condition Monitor (P/S): 10 / 11
    • Limits: Physical 4, Mental 6, Social 8
    • Armor: 9 (Lined Coat or armored clothing)

    Active Skills

    • Sorcery Group: 6 (Spellcasting, Counterspelling, Ritual Spellcasting)
    • Conjuring Group: 6 (Summoning, Binding, Banishing)
    • Influence Group: 5 (Con, Etiquette, Negotiation)
    • Perception: 5
    • Sneaking (Urban): 4
    • Assensing: 5Astral Combat: 4

    Qualities

    • Mentor Spirit (Doom): Provides bonuses to destruction-based magic; embodies the desire to end life and hastening an “apocalypse” for those he targets.
    • Bad Rep: Known as a malevolent urban myth inhabiting the city’s tunnels.
    • Guts: +2 to resist fear and intimidation.
    • Focused Concentration 3: Allows him to sustain mental suggestions without penalty

    Spells

    • Influence: Used for his “whispered suggestions,” implanting post-hypnotic commands that victims carry out as their own ideas.
    • Control Thoughts: Nudges people toward fatal movements or losing their grip.
    • Chaotic World: Induces hesitation and confusion, causing victims to falter at critical moments.
    • Pollutant Stream: A specialized toxic spell that blasts targets with a concentrated stream of filth.
    • Physical Mask: Maintains his calm, polite appearance to hide his true, warped nature.Silence: Used to move through maintenance shafts and tunnels without sound

    The Hands

    The hands crawling through tunnels
    • Initiative: 15 + 2D6 (Physical); 13 + 3D6 (Astral)
    • Skills: Assensing, Astral Combat, Con, Gymnastics, Intimidation, Perception, Unarmed Combat

    Powers

    • Energy Drain (Karma): Feeds on the negative emotional energy and disrupted fate of his victims.
    • Compulsion (Sorrow): Deepens loss and loneliness, nudging victims toward suicide.
    • Shadow Cloak: Allows the Hands to reach out from deep, supernatural shadows.
    • Silence: Ensures the Hands can grasp victims without a sound.
    • Materialization: Used to manifest as enormous gray hands in the physical world.
    • Fear: Living creatures are struck by unreasoning terror when the Hands appear
  • Shadowrun 5: Unique uses of Astral Projection.

    Shadowrun 5: Unique uses of Astral Projection.

    In many campaigns, Astral Projection is overlooked, or it is only used as scouting tool. We all know it. Magician will use astral projection to get close to the target, and scout area and then it will give information to the team.

    But, when you think about it, isn’t it too narrow minded, or too little? This power is unlocked only to full mages, and there should be some more utility to it, right?

    Through years of playing magician in Shadowrun, I encountered several situations, where astral projection was more just scouting tool, and in some cases completely turned tide of battle. Let’s look into those!

    Breaking stalemate aka Astral “Boo!”

    I rank this one up, because it was most funny one. Our group was pinned down by very competent killer, and they couldn’t move. Trying to advance would mean serious injuries as killer was holding the stairs. Situation was dire and grim, as fast response team was on the way, and something had to be done.

    Magician of the group got an idea, they ran into toilet on bottom floor, locked themself in it and declared astral projection. As shoot out continued, the mage manifested behind the killer and did “Boo!”.

    How would professional killer react in such manner, they know there shouldn’t be anyone behind them. They looked over over startled and saw astral manifestation of the mage. This little opening gave rest of the party enough time, to make breakthrough and take the killer out.

    Simple, effective, no rolls, just RP. It can be done in many more situations. Especially if astral form of mage is well described to GM. You see, NPCs are not masters of magic most of the time, so they might confuse mage form for a spirit and focus fire on the form, instead of party. Until, they realize that it does nothing. But, even one round can give enough opening to change course of battle.

    Also this is perfect opportunity to use Intimidate skill, as target can hear you and see you.

    Meeting contacts while projecting

    This one is more straightforward. Is meeting contact too dangerous? Is it too far? This is good use of astral projection. Meet the contact while manifesting.

    There is a catch though. You can’t write down anything, you can’t see screens and what is written on the papers, so every information has to be said verbally to you. Why this matters?

    Because as player you can write it down, but your character can’t. In this type of encounters excel Hermetic Mages over Shamans, as their Logic gives them higher chances on Memory tests.

    Yes, GMs! If your mage player wants to do legwork through astral perception, make them do memory tests on every piece of information. This punishes dump stats, and makes game more believable.

    When someone describes you whole floor plan by words, if you are not genius with trained memory, you will bring back only fragments of it.

    Bad day to be Dual-Natured.

    Paracritter hunting

    This is where you use your Parazoologist contacts, and your Parazoology Knowledge Skill. It can be good easy game session to make money.

    Find paracritter, which is dual-natured and has no means to defend itself in ranged combat. Use your spirits to find such critter and tell your team.

    You do not have to leave your bedroom, just rest on your bed and go hunting with your friends. While your street sam, rigger friends chase down creature, give it some good bully.

    Float above, cast mana barriers, manabolts and other mana spells to slow down and hunt down creature. It will be much easier for your friends to deal with it, when you manage to corner it in mana barrier, or you bring down it’s dices by constant stun damage.

    This approach needs bit investment in drain management, but, it’s worth considering. You do not have to be everywhere physically.

    Hanging around

    Astral space is interesting place. Dangerous, but interesting. In many cases, much more interesting then bubble gum stuck to your ceiling, above your bed, which you observe every night.

    Why not utilize it? If you have Knowledge skill: Awakened Hangout, you can actually go out, without leaving your home. You can flow through mysterious astral plane and appear on places where magicians, awakened creatures appear.

    You may indulge in astral sex (yes it is possible, and very very intense), you may meet new contacts, magical groups. Heck you may even meet vampires, or other infected! If that is your alluring path of progression, you may even convince them to change you (For service or cash), into infected. Immortality and mages go well together.

    Actual exploration

    This one is more session building. You can help your GM. Sometimes coming up with idea what to do next, can be rather daunting. Search magazines about cryptids, conspiracies, mythical places, interesting historical places.

    Aside of your shadowrunning friends, you are the one, which can explore them within a moment and with no cost. Tell your GM about your idea visiting picked place and send them materials. I’m sure they will be happy, that they will get some interesting idea to work with.

    When you start the game, go into astral exploration mode and check that place out. Maybe there grow interesting expensive awakened herbs, awakened critters, mysterious glowing runes or unknown mana barrier? Maybe in those ancient Celtic ruins sits some spirit, which notices you and asks you to come to him!

    In any way, after scouting convince your team, that some holiday and change of Seattle air, would benefit them. And then, of you go on interesting adventure, where you can change and experiment with different styles of game!

    Conclusion

    And there you have it! Quick article. I hope it will give you some ideas for usage of Astral Perception in Shadowrun 5, and how to utilize it aside from simple scouting.

    If you made this far, I love you and thank you for reading!

  • IPSec configuration Ubuntu and Debian

    IPSec configuration Ubuntu and Debian

    What is happening with IPSec?

    Maybe you noticed, or not, but Fortinet company is abandoning SSL VPN and pushes IPSec VPN. They reason it with SSL VPN not being secure anymore. Which is fine, I guess? Maybe your company already moved to new versions of FortiVPN and you are now dealing with this, or it awaits you.

    Better security is great, right? Well, if you are using Windows or MacOS, you will likely face very few issues. If you are Linux user, you will be facing problems.

    While Fortinet pushed this change, they didn’t put advanced IPSec capabilities from their free FortiVPN clients, for Linux. Those configurations are behind a PAYWALL. You are required to purchase 50 licences / year, to get this thing. It’s truly “Create problem, sell solution thing”, but fret not!

    Challenges

    For Windows and MacOS users, new model comes with some benefits. Authentication can be tied with EntraID MFA and so on. But, on Linux it is bit trickier. So far, to my knowledge, only working way how to connect to IPSec VPN from Linux is by using Strongswan.

    Strongswan is TUI based package to configure and connect to VPNs. Now you probably start to see the problem. If your company will be using EntraID, even Strongswan is not going to help you. You will have hard times to conjure pop up Microsoft Account login window. So, when using Linux, you better hope, your company uses only basic IPSec.

    Another challenge you will be facing, is that every distribution has different approaches/packages. Fedora might use libreswan instead of strongswan and donn’t make started on NixOS.

    Let’s hope you are not System Administrator in some Software company, which has many Developer contractors and every has own distro, haha!

    IPSec and Debian?

    When this change first hit me, I was truly struggling to find way how to connect to VPN. I was trying Libreswan, Strongswan and it took me several days. We were going back and forth. But, I finally succeeded and found way, how to make Strongswan work on Debian based distros.

    Note: This what I will show you, is ONLY for specific configuration. That configuration is, as follows.

    • Debian based distribution (Frankly it goes same on every debian based distro)
    • IPSec with EAP and PSK configured.
    • Alignment of Jupiter and Saturn.

    How to configure IPSec on Debian.

    Let’s start with preparation of packages and our service:

    Now, in your /etc directory, you will have two files ipsec.conf and ipsec.secrets.

    Those are your main files that will interest you.

    For EAP/PSK configuration you will first want to edit ipsec.conf file. Paste in this:

    Note: left = client, right = remote gateway.

    Config files

    Green is name of your connection.

    Yellow is guide what to fill, you can figure it out.

    Once you have these set, let’s go on ipsec.secrets

    VPN operation

    Now when you have your configuration files ready, you can start to operate your VPN.

    Conclusion

    And there you have it! It will really depend on your configuration and how complicated you have it.

    This article should serve you only as baseline from where to start. It can also serve as template, for your FortiGate configuration, so transition is painless for MacOs, Windows and even Linux.

    If I could choose, I wouldn’t buy/use Fortinet at all.