Category: IT Articles

Linux, Internet, Cybersecurity, general IT knowledge, IT news, Networking, Work in IT, Windows, MacOS.

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

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

  • 7 useful security headers for NGINX.

    7 useful security headers for NGINX.

    So! I saw amazing video on the YouTube, which explains beautifully something obscure about NGINX, and that is headers. I had quite problem to understand headers, as in NGINX lore, they are quite on background. You will learn how to set it as reverse proxy, and then you just stop thinking about it. Only after some time, you will understand that there is more to NGINX as a whole. I stumbled across this video, and I found it super helpful. So, you do not have to watch it (I recommend you should), I wrote down most important headers and you can just copy, paste them into your configuration. Video link is down bellow!

    server_tokens off;

    Hides the NGINX version from HTTP headers. This makes it harder for attackers to identify the exact NGINX version and search for version-specific vulnerabilities.

    Place this in `/etc/nginx/nginx.conf`, inside the `http` block.

    more_clear_headers Server;

    Completely removes the `Server` header from HTTP responses.

    Requires the headers-more module:

    apt install libnginx-mod-http-headers-more-filter

    Place this in the public-facing NGINX `server` block, usually in `/etc/nginx/sites-available/*.conf`.

    add_header Content-Security-Policy “default-src ‘self’;” always;

    Restricts the browser to loading content only from the same origin as the website, unless other CSP rules allow additional sources. This helps control where scripts, images, styles, and other resources can be loaded from.

    Place this on the public-facing NGINX reverse proxy, usually inside the website’s `server` block.

    add_header X-Frame-Options “DENY” always;

    Prevents the website from being loaded inside an iframe. This helps protect against clickjacking attacks.

    Place this in the public-facing NGINX `server` block, or globally in the `http` block if it should apply to all sites.

    add_header X-Content-Type-Options “nosniff” always;

    Stops browsers from guessing file types. This prevents files from being interpreted as something else, such as treating an uploaded image or CSS file as executable JavaScript. This helps reduce some XSS risks.

    Place this in the public-facing NGINX `server` block, or globally in the `http` block if it should apply to all sites.

    proxy_hide_header X-Runtime;

    Removes the `X-Runtime` header from backend responses. This hides backend request processing time from clients.

    Use this on the reverse proxy, inside the relevant `location` or `server` block.

    proxy_hide_header X-Powered-By;

    Removes the `X-Powered-By` header from backend responses. This avoids exposing backend technologies such as PHP, Express, ASP.NET, Laravel, or framework versions.

    Use this on the reverse proxy, inside the relevant `location` or `server` block.

    Original explanation on YouTube:

    This text I wrote based on amazing video by The Lazy SysAdmin

  • Internet battlefield?

    Internet battlefield?

    DISCLAIMER: Article is not about putting shame on any citizen on any country. It’s about cyber warfare.

    Welcome in new age of warfare.

    Yes. The internet is now a battlefield on more levels than many people realize. There are conflicts between nations, AI wars, bot wars, and information wars. The topic is large enough to fill an entire book. I will try to avoid politics as much as possible, although in some cases it is difficult to do so.

    So, what Internet Battlefield are we talking about today? Unfortunately, the more political one: conflict between nations. Whether people notice it or not, it is happening. Try spinning up a server, install rsyslog, and watch your auth.log. You will most likely see countless login attempts coming from different IP addresses. Many of them will originate from China, Iran, or Russia, although you should not be fooled into thinking they come only from those countries. You will also see activity from Europe and the United States. But who are these actors, and what are they trying to achieve?

    In 99.99% of cases, they are not people at all. They are automated bots. These systems continuously scan the internet for anything they can reach and attempt to gain access through what we call brute-force attacks. A brute-force attack is a method where an attacker repeatedly tries different passwords until one eventually works. The goal is simple: gain access.

    The reality is that countries such as Russia, Iran, China, and North Korea invest heavily in cyberwarfare, automated tools, and the training of skilled hackers. They are not amateurs. Many of these groups are highly capable and experienced.

    Microsoft Digital Defense Report 2025

    EU consistently targeted by diverse yet convergent threat groups

    How it shows up in our daily lives?

    During the COVID-19 pandemic, several Russian hacker groups targeted Western hospitals (links bellow), attempting to disrupt operations and create chaos. In many cases, they were not seeking money or publicity. Their goal was simply to cause damage while hospitals were already under tremendous pressure. Whether one agrees with every example or not, it illustrates how hostile parts of the internet have become.

    I have worked in IT for years, and one thing is clear: anything with a public IP address will eventually attract attention. What is even more interesting is that modern threats are no longer limited to directly exposed systems. If a service is reachable and vulnerable, attackers will find it. If they gain access, they will often steal data, deploy ransomware, or use the compromised system for further attacks.

    So yes, the internet is a battlefield, and sooner or later everyone becomes part of it. Every malware campaign and every scam website originates somewhere. Some hacker groups are ordinary criminals interested only in money, while others receive support from governments. The profits generated from cyber crime can fund further operations and keep these groups active.

    When you receive an email claiming that someone has compromising photos of you and demanding payment, sending money does not simply solve the problem. In many cases, it helps fund future criminal activity. The same principle applies in the corporate world. You may think, “If my company gets hacked, that’s not my problem.” In some situations, that attitude is understandable. Many companies do not invest enough in security until after a breach occurs. However, it should still matter to you.

    What can we do as pawns on this battlefield?

    Every data breach and every successful attack generates income for cyber criminals. That income allows them to continue operating and launching new attacks. Every secured device, every hardened server, and every prevented compromise reduces their chances of success.

    So what can we do? Anyone can learn basic cyber self-defense. Anyone can learn good security habits, and every IT professional can contribute by properly securing the systems they manage. A teenager can begin studying cybersecurity (I can recommend this one, it’s fun!) online and develop valuable skills. It not only helps you understand how the internet works and how threats operate, but it can also open the door to a rewarding and engaging career.

    Older people can learn these skills as well. In fact, many of the most important security practices can be learned in a single day of reading and practice. Strong passwords, multi-factor authentication, software updates, and basic awareness already go a long way.

    I will be posting guides and videos here about staying safe online. I will end this post with a famous saying:

    “Do not feed the trolls.”