Skip to content

Game servers (LXC)

Three dedicated game servers run on the Proxmox node pve01 as unprivileged LXC containers: Satisfactory, Factorio, and Valheim. They live on the NUC because all three are x86-64 only — they cannot run on the arm64 Raspberry Pi k3s cluster. LXC (rather than a full VM) keeps them light and lets every step run through pct / pct exec from the host, so no container needs its own SSH.

Each server is one parameterised container plus a small install-<game>.sh that installs the game (SteamCMD or the vendor tarball) and wires it up as a systemd service that starts on boot and restarts on failure.

Game CT ID Hostname IP vCPU / RAM / disk Ports Service
Satisfactory 200 satisfactory-01 192.168.1.14 4 / 12 GiB / 20 GiB 7777 udp+tcp, 8888 tcp satisfactory
Factorio 201 factorio-01 192.168.1.15 2 / 4 GiB / 8 GiB 34197 udp factorio
Valheim 202 valheim-01 192.168.1.16 4 / 6 GiB / 12 GiB 2456-2457 udp valheim

The sizes fit comfortably on pve01 (62 GiB RAM, ~42 GiB free before these; rootfs on local-lvm, ~660 GiB free). Satisfactory gets the most RAM because its simulation is the heaviest of the three, especially late-game.

Prerequisites

  • SSH access as root to pve01 (192.168.1.4) — the same prerequisite as the GitHub runner. Every script is streamed there over SSH (bash -s); nothing is copied onto the host or the containers.
  • The Debian 12 LXC template in the node's store (fetched once, below).
  • All three scripts live in this folder and are embedded below. The install scripts are streamed into the container with pct exec <ctid> -- bash -s, so the containers themselves need no SSH, no keys, and no login.

Confirm the target IPs are free first

ip= in the container config is static, so a clash with an existing host is a silent conflict, not a rejection. Confirm each of 192.168.1.14-16 is unused from a LAN machine before provisioning: ping 192.168.1.14 (expect no reply) and check it is not in the network layout.

Fetch the LXC template (once)

Run this first: it ensures a Debian 12 template is in the local store and downloads it only if missing. Every container reuses it.

ssh root@192.168.1.4 'bash -s' < fetch-lxc-template.sh
fetch-lxc-template.sh
#!/usr/bin/env bash
# Ensure a Debian 12 LXC template is in the Proxmox template store. Run first, as
# root on pve01, before provisioning any game-server container. Downloads only if
# a debian-12-standard template is not already present -- every container reuses
# it, so the provision script never downloads.
set -euo pipefail

STORE="${STORE:-local}"   # 'local' holds vztmpl content on this node

pveam update

if pveam list "$STORE" | grep -q 'debian-12-standard'; then
  echo "Debian 12 template already present:"
  pveam list "$STORE" | grep 'debian-12-standard'
  exit 0
fi

# Pick the newest debian-12-standard the mirror offers (name carries the point
# release, e.g. debian-12-standard_12.7-1_amd64.tar.zst).
tmpl="$(pveam available --section system | awk '/debian-12-standard/{print $2}' | sort -V | tail -1)"
[ -n "$tmpl" ] || { echo "No debian-12-standard template offered by 'pveam available'." >&2; exit 1; }

echo "Downloading $tmpl to $STORE"
pveam download "$STORE" "$tmpl"

Provision the containers

One parameterised script creates any of the three containers; call it once per game with that game's spec. It is idempotent — if the CT id already exists it prints a message and exits without changes.

ssh root@192.168.1.4 'bash -s -- 200 satisfactory-01 192.168.1.14 4 12288 20' < provision-game-lxc.sh
ssh root@192.168.1.4 'bash -s -- 201 factorio-01 192.168.1.15 2 4096 8' < provision-game-lxc.sh
ssh root@192.168.1.4 'bash -s -- 202 valheim-01 192.168.1.16 4 6144 12' < provision-game-lxc.sh
provision-game-lxc.sh
#!/usr/bin/env bash
# Create ONE unprivileged LXC container for a game server on pve01. Run as root
# on pve01. One parameterised provisioner for all three game servers -- the game
# itself is installed afterwards with the matching install-*.sh via `pct exec`.
#
#   Usage: ./provision-game-lxc.sh <ctid> <hostname> <ip> <cores> <mem_mb> <disk_gb> [swap_mb]
#     ctid      container id, e.g. 200
#     hostname  e.g. satisfactory-01
#     ip        LAN IPv4 (no mask), e.g. 192.168.1.14   -- /24 gw .1 by default
#     cores     vCPU count
#     mem_mb    RAM limit in MiB
#     disk_gb   rootfs size in GiB (on local-lvm)
#     swap_mb   optional swap in MiB (default 512)
#
# Overridable via env: CIDR (24), GW (192.168.1.1), BRIDGE (vmbr0),
#   STORAGE (local-lvm), NAMESERVER (192.168.1.60 -- the LAN resolver),
#   TEMPLATE (auto-detected newest debian-12-standard in 'local').
set -euo pipefail

if [ "$(id -u)" -ne 0 ]; then echo "Run as root on pve01." >&2; exit 1; fi

CTID="${1:?ctid}"; HOSTNAME="${2:?hostname}"; IP="${3:?ip, e.g. 192.168.1.14}"
CORES="${4:?cores}"; MEM="${5:?mem_mb}"; DISK="${6:?disk_gb}"; SWAP="${7:-512}"

CIDR="${CIDR:-24}"; GW="${GW:-192.168.1.1}"; BRIDGE="${BRIDGE:-vmbr0}"
STORAGE="${STORAGE:-local-lvm}"
NAMESERVER="${NAMESERVER:-192.168.1.60}"   # the LAN resolver (see index.md network layout)

# Idempotent: if the CT already exists, do nothing and succeed.
pct status "$CTID" >/dev/null 2>&1 && { echo "CT $CTID already exists -- nothing to do."; exit 0; }

# Resolve the template (newest debian-12-standard in 'local') unless pinned.
TEMPLATE="${TEMPLATE:-$(pveam list local 2>/dev/null | awk '/debian-12-standard/{print $1}' | sort -V | tail -1)}"
[ -n "$TEMPLATE" ] || { echo "No debian-12-standard template in 'local' -- run fetch-lxc-template.sh first." >&2; exit 1; }

# Unprivileged for isolation. features:
#   keyctl=1 -- SteamCMD needs the keyring syscalls in an unprivileged CT
#               (Satisfactory + Valheim install via Steam); harmless for Factorio.
#   nesting=1 -- lets systemd run cleanly as the container's init.
pct create "$CTID" "$TEMPLATE" \
  --hostname "$HOSTNAME" \
  --cores "$CORES" --memory "$MEM" --swap "$SWAP" \
  --rootfs "${STORAGE}:${DISK}" \
  --net0 "name=eth0,bridge=${BRIDGE},ip=${IP}/${CIDR},gw=${GW}" \
  --nameserver "$NAMESERVER" \
  --unprivileged 1 \
  --features nesting=1,keyctl=1 \
  --onboot 1

pct start "$CTID"
echo "CT $CTID ($HOSTNAME) created at ${IP}/${CIDR} and started."
echo "Install the game with:  ssh root@192.168.1.4 'pct exec $CTID -- bash -s' < install-<game>.sh"

Why keyctl=1 and nesting=1

The containers are unprivileged for isolation. keyctl=1 is required because SteamCMD (used by Satisfactory and Valheim) calls the kernel keyring, which an unprivileged container blocks by default — without it the Steam install fails. nesting=1 lets systemd run cleanly as the container's init. Both are set on all three for consistency; only Steam actually needs keyctl.

Install each server

The install scripts run inside the container via pct exec. Each installs the game, creates a service user (Steam refuses to run as root), writes a systemd unit, and starts it. Re-running a script updates the server in place and never clobbers your saves or edited config.

Satisfactory

ssh root@192.168.1.4 'pct exec 200 -- bash -s' < install-satisfactory.sh
install-satisfactory.sh
#!/usr/bin/env bash
# Install the Satisfactory Dedicated Server INSIDE its LXC container, as a
# systemd service. Drive it from pve01 -- nothing is copied onto the host or CT:
#
#   ssh root@192.168.1.4 'pct exec 200 -- bash -s' < install-satisfactory.sh
#
# pct exec runs this as root inside the container. Idempotent: re-running updates
# the server in place and rewrites the unit.
set -euo pipefail

APPID=1690800                 # Satisfactory Dedicated Server (SteamCMD)
SVC_USER=steam
STEAM_HOME=/opt/steam
STEAMCMD="$STEAM_HOME/steamcmd"
INSTALL_DIR=/opt/satisfactory

export DEBIAN_FRONTEND=noninteractive
apt-get update -qq
# lib32gcc-s1: SteamCMD itself is 32-bit (the SF server binary is 64-bit).
apt-get install -y --no-install-recommends ca-certificates curl tar lib32gcc-s1

# Dedicated service user (SteamCMD refuses to run as root).
id "$SVC_USER" >/dev/null 2>&1 || \
  useradd --system --create-home --home-dir "$STEAM_HOME" --shell /usr/sbin/nologin "$SVC_USER"
install -d -o "$SVC_USER" -g "$SVC_USER" "$INSTALL_DIR" "$STEAMCMD"

# SteamCMD (distro-agnostic tarball) -- fetch once.
if [ ! -x "$STEAMCMD/steamcmd.sh" ]; then
  runuser -u "$SVC_USER" -- bash -c \
    "curl -fsSL https://steamcdn-a.akamaihd.net/client/installer/steamcmd_linux.tar.gz | tar -xz -C '$STEAMCMD'"
fi

# Install / update the server (anonymous login; SF allows it).
# SteamCMD's FIRST app_update after a fresh bootstrap frequently fails with
# "Missing configuration" (sometimes "No subscription") -- it self-updates on
# that run and doesn't have the app metadata cached yet. Retry a few times; the
# identical command succeeds once SteamCMD has bootstrapped.
n=0
until runuser -u "$SVC_USER" -- env HOME="$STEAM_HOME" \
        "$STEAMCMD/steamcmd.sh" +force_install_dir "$INSTALL_DIR" \
        +login anonymous +app_update "$APPID" validate +quit; do
  n=$((n + 1))
  [ "$n" -ge 5 ] && { echo "SteamCMD failed to install $APPID after $n attempts." >&2; exit 1; }
  echo "SteamCMD attempt $n failed (likely first-run 'Missing configuration') -- retrying in 5s..."
  sleep 5
done

# systemd unit. The server keeps SIMULATING the factory whenever the service is
# up -- a dedicated server never pauses when empty (that is a listen-server-only
# behaviour), so with Restart=always + onboot the world ticks 24/7 regardless of
# who is connected. Do not add any "pause when empty" launch flag.
# ExecStartPre auto-updates on every start; the leading '-' makes a failed update
# (e.g. Steam briefly unreachable) non-fatal so the server still starts on the
# last-known-good build.
cat > /etc/systemd/system/satisfactory.service <<UNIT
[Unit]
Description=Satisfactory Dedicated Server
After=network-online.target
Wants=network-online.target

[Service]
Type=simple
User=$SVC_USER
Group=$SVC_USER
WorkingDirectory=$INSTALL_DIR
ExecStartPre=-$STEAMCMD/steamcmd.sh +force_install_dir $INSTALL_DIR +login anonymous +app_update $APPID validate +quit
ExecStart=$INSTALL_DIR/FactoryServer.sh
Restart=always
RestartSec=15
# ExecStartPre auto-updates via SteamCMD on every start; a large post-patch update
# can take many minutes, so allow up to an hour before systemd treats the whole
# start (ExecStartPre included) as timed out and kills it. The '-' prefix only
# ignores a nonzero *exit*, not a timeout kill, so this bound has to be generous.
TimeoutStartSec=3600
# Give the world a chance to save on stop.
KillSignal=SIGINT
TimeoutStopSec=45

[Install]
WantedBy=multi-user.target
UNIT

systemctl daemon-reload
systemctl enable --now satisfactory.service

echo
echo "Satisfactory installed. Server settings live in:"
echo "  $INSTALL_DIR/FactoryGame/Saved/Config/LinuxServer/  (created on first run)"
echo "Ports: 7777/udp + 7777/tcp (game/API), 8888/tcp (reliable messaging)."

Ports: 7777 UDP and TCP (game traffic + the HTTPS API), plus 8888 TCP (reliable messaging). As of patch 1.1.0.0 a Satisfactory server needs both ports; the old 15000/15777 ports are gone.

The world keeps simulating with nobody connected

A Satisfactory dedicated server never pauses when empty — that pause is a listen-server (host-in-game) behaviour only. With the systemd service set to onboot + Restart=always, the factory ticks 24/7 regardless of who is online. Do not add any "pause when empty" flag. Rebooting pve01 brings the container and the world straight back.

Configure it. Satisfactory has no config file to pre-seed — you administer it from the game client:

  1. Connect to the server (below). On first connect the client prompts you to set an Admin Password and name the server, then to Create Game (a new save) or load one.
  2. In-game, open Server Manager → the server → Settings to change autosave interval, the "auto-pause" toggle (leave off to keep simulating), restart-on-crash, and network quality.
  3. On disk these land in /opt/satisfactory/FactoryGame/Saved/Config/LinuxServer/*.ini (created on first run) and saves in .../Saved/SaveGames/. Editing the INI then systemctl restart satisfactory also works.

Connect. In the game's main menu open Server Manager, add a server, and enter 192.168.1.14:7777. Enter the admin password you set to manage it; other players just need the server on the LAN (and the game password, if you set one).

Factorio

ssh root@192.168.1.4 'pct exec 201 -- bash -s' < install-factorio.sh
install-factorio.sh
#!/usr/bin/env bash
# Install the Factorio headless server INSIDE its LXC container, as a systemd
# service. Drive it from pve01:
#
#   ssh root@192.168.1.4 'pct exec 201 -- bash -s' < install-factorio.sh
#
# Factorio's headless build is a plain tarball from factorio.com -- no Steam, no
# account needed for the stable headless download. Idempotent: re-running upgrades
# the binary in place and never clobbers an existing save or server-settings.json.
set -euo pipefail

SVC_USER=factorio
INSTALL_DIR=/opt/factorio
DL=https://factorio.com/get-download/stable/headless/linux64   # redirects to the current stable tarball
SAVE="$INSTALL_DIR/saves/world.zip"
SETTINGS="$INSTALL_DIR/data/server-settings.json"

export DEBIAN_FRONTEND=noninteractive
apt-get update -qq
apt-get install -y --no-install-recommends ca-certificates curl xz-utils tar

id "$SVC_USER" >/dev/null 2>&1 || \
  useradd --system --create-home --home-dir "$INSTALL_DIR" --shell /usr/sbin/nologin "$SVC_USER"

# Fetch + unpack the headless build (tar.xz extracts to ./factorio).
echo "Downloading Factorio headless (stable)..."
curl -fsSL "$DL" | tar -xJ -C /opt
chown -R "$SVC_USER:$SVC_USER" "$INSTALL_DIR"

BIN="$INSTALL_DIR/bin/x64/factorio"

# Seed a server config from the shipped example (kept if it already exists so
# your edits survive re-runs).
if [ ! -f "$SETTINGS" ]; then
  install -o "$SVC_USER" -g "$SVC_USER" -m 0644 \
    "$INSTALL_DIR/data/server-settings.example.json" "$SETTINGS"
  echo "Seeded $SETTINGS from the example -- edit it (name, description, password, visibility)."
fi

# Create a first map if none exists yet (default map/gen settings).
if ! ls "$INSTALL_DIR"/saves/*.zip >/dev/null 2>&1; then
  install -d -o "$SVC_USER" -g "$SVC_USER" "$INSTALL_DIR/saves"
  runuser -u "$SVC_USER" -- "$BIN" --create "$SAVE"
fi

# systemd unit. Factorio PAUSES the simulation when the last player leaves (by
# design -- it saves CPU and the sim is deterministic). To keep it ticking with
# nobody connected, add --no-auto-pause to ExecStart (see the docs).
cat > /etc/systemd/system/factorio.service <<UNIT
[Unit]
Description=Factorio Headless Server
After=network-online.target
Wants=network-online.target

[Service]
Type=simple
User=$SVC_USER
Group=$SVC_USER
WorkingDirectory=$INSTALL_DIR
ExecStart=$BIN --start-server-load-latest --server-settings $SETTINGS
Restart=always
RestartSec=10
# Factorio autosaves and exits cleanly on SIGINT.
KillSignal=SIGINT
TimeoutStopSec=30

[Install]
WantedBy=multi-user.target
UNIT

systemctl daemon-reload
systemctl enable --now factorio.service

echo
echo "Factorio installed. Config: $SETTINGS  (+ map-gen-settings.json / map-settings.json in data/)."
echo "Port: 34197/udp."

Ports: 34197 UDP.

Configure it. Factorio reads a JSON config the install script seeds from the shipped example and then leaves alone:

  • /opt/factorio/data/server-settings.json — server name, description, max_players, visibility (public/lan), game_password, autosave_interval, and whether commands are allowed. Edit it, then systemctl restart factorio.
  • /opt/factorio/data/map-gen-settings.json and map-settings.json — world generation (resource richness, biters, pollution) for a new map; pass them to --create when generating a world.
  • /opt/factorio/data/server-adminlist.json — usernames with admin rights.

Factorio pauses when empty — by design

Unlike Satisfactory, a Factorio server pauses the simulation when the last player leaves (the sim is deterministic, so this just saves CPU). To keep it running with nobody connected, add --no-auto-pause to ExecStart in /etc/systemd/system/factorio.service and restart. Left as-is, it resumes instantly when someone joins.

Connect. In the main menu open Multiplayer → Connect to address and enter 192.168.1.15:34197. Enter the game_password if you set one.

Valheim

Valheim needs a server name, a world name, and a password (minimum 5 characters) on first install. They are written to /etc/valheim/valheim.env, which you edit later to reconfigure.

ssh root@192.168.1.4 'pct exec 202 -- bash -s -- "Homelab Valheim" "Dedicated" "<password>"' < install-valheim.sh
install-valheim.sh
#!/usr/bin/env bash
# Install the Valheim Dedicated Server INSIDE its LXC container, as a systemd
# service. Drive it from pve01:
#
#   ssh root@192.168.1.4 'pct exec 202 -- bash -s -- "<server-name>" "<world>" "<password>"' < install-valheim.sh
#
# Server name / world / password are baked into /etc/valheim/valheim.env, which
# you edit later to reconfigure (then restart the service). Idempotent: re-running
# updates the server and rewrites the unit but KEEPS an existing env file.
set -euo pipefail

APPID=896660                  # Valheim Dedicated Server (SteamCMD)
GAME_APPID=892970             # Valheim client app id -- the server must export it
SVC_USER=valheim
STEAM_HOME=/opt/valheim
STEAMCMD="$STEAM_HOME/steamcmd"
INSTALL_DIR="$STEAM_HOME/server"
DATA_DIR="$STEAM_HOME/data"
ENV_FILE=/etc/valheim/valheim.env

NAME="${1:-Homelab Valheim}"
WORLD="${2:-Dedicated}"
PASSWORD="${3:-}"

# Only enforce the password rule when we are about to WRITE the env file. On a
# re-run with an env file already in place, args may be omitted.
if [ ! -f "$ENV_FILE" ]; then
  [ -n "$PASSWORD" ] || { echo "A password is required on first install (min 5 chars): pass it as the 3rd arg." >&2; exit 1; }
  [ "${#PASSWORD}" -ge 5 ] || { echo "Valheim requires a password of at least 5 characters." >&2; exit 1; }
  # name/world/password are written into a shell-sourced env file (below), so a value
  # containing " $ ` or \ would corrupt it. Reject those outright rather than trust prose.
  # A case-glob per char is portable (no dependency on a grep bracket-expression dialect).
  for _ch in '"' '$' '`' '\'; do
    case "${NAME}${WORLD}${PASSWORD}" in
      *"$_ch"*) echo 'name/world/password must not contain the characters " $ ` or \ (they corrupt the sourced env file).' >&2; exit 1;;
    esac
  done
fi

export DEBIAN_FRONTEND=noninteractive
apt-get update -qq
apt-get install -y --no-install-recommends ca-certificates curl tar lib32gcc-s1

id "$SVC_USER" >/dev/null 2>&1 || \
  useradd --system --create-home --home-dir "$STEAM_HOME" --shell /usr/sbin/nologin "$SVC_USER"
install -d -o "$SVC_USER" -g "$SVC_USER" "$INSTALL_DIR" "$DATA_DIR" "$STEAMCMD"

if [ ! -x "$STEAMCMD/steamcmd.sh" ]; then
  runuser -u "$SVC_USER" -- bash -c \
    "curl -fsSL https://steamcdn-a.akamaihd.net/client/installer/steamcmd_linux.tar.gz | tar -xz -C '$STEAMCMD'"
fi

runuser -u "$SVC_USER" -- env HOME="$STEAM_HOME" \
  "$STEAMCMD/steamcmd.sh" +force_install_dir "$INSTALL_DIR" \
  +login anonymous +app_update "$APPID" validate +quit

# Config env file (name/world/password/visibility). Kept on re-runs so your
# edits survive; mode 0640 root:valheim so the password is not world-readable.
if [ ! -f "$ENV_FILE" ]; then
  install -d -m 0755 /etc/valheim
  umask 027
  cat > "$ENV_FILE" <<ENV
# Valheim server config -- edit, then: systemctl restart valheim
# Values are shell-sourced by start.sh, so keep the quotes (needed for the space
# in a name) and avoid " \$ or backtick inside the values.
VALHEIM_NAME="$NAME"
VALHEIM_WORLD="$WORLD"
VALHEIM_PASSWORD="$PASSWORD"
# 1 = list in the public community server browser; 0 = private (join by IP or
# crossplay code). Password is still required either way.
VALHEIM_PUBLIC=0
ENV
  chown root:"$SVC_USER" "$ENV_FILE"; chmod 0640 "$ENV_FILE"
fi

# Launch wrapper -- handles the Steam env the server needs and quotes the (possibly
# spaced) name/world safely.
cat > "$STEAM_HOME/start.sh" <<WRAP
#!/usr/bin/env bash
set -euo pipefail
cd "$INSTALL_DIR"
export SteamAppId=$GAME_APPID
export LD_LIBRARY_PATH="$INSTALL_DIR/linux64:\${LD_LIBRARY_PATH:-}"
# shellcheck disable=SC1090
source "$ENV_FILE"
exec ./valheim_server.x86_64 \\
  -name "\$VALHEIM_NAME" -port 2456 -world "\$VALHEIM_WORLD" \\
  -password "\$VALHEIM_PASSWORD" -public "\${VALHEIM_PUBLIC:-0}" \\
  -crossplay -savedir "$DATA_DIR"
WRAP
chmod 0755 "$STEAM_HOME/start.sh"

# Valheim keeps simulating while empty (no auto-pause), so onboot + Restart=always
# gives a 24/7 world.
cat > /etc/systemd/system/valheim.service <<UNIT
[Unit]
Description=Valheim Dedicated Server
After=network-online.target
Wants=network-online.target

[Service]
Type=simple
User=$SVC_USER
Group=$SVC_USER
WorkingDirectory=$INSTALL_DIR
ExecStart=$STEAM_HOME/start.sh
Restart=always
RestartSec=15
# Valheim saves the world on SIGINT/SIGTERM; give it time.
TimeoutStopSec=45

[Install]
WantedBy=multi-user.target
UNIT

systemctl daemon-reload
systemctl enable --now valheim.service

echo
echo "Valheim installed. Config: $ENV_FILE  (edit + 'systemctl restart valheim')."
echo "Worlds saved under: $DATA_DIR/worlds_local/   Ports: 2456-2457/udp."

Ports: 2456-2457 UDP (2456 game, 2457 query — the server uses the given port and port+1).

The password is briefly visible in the host process table

Passing it as the third argument means it shows in ps on pve01 while pct exec runs, and it is stored in /etc/valheim/valheim.env (mode 0640, root:valheim). Use a password dedicated to this server, and change it via that env file if it needs rotating. Valheim also rejects a password that is contained in the server name.

Configure it. Edit /etc/valheim/valheim.env then systemctl restart valheim:

  • VALHEIM_NAME, VALHEIM_WORLD, VALHEIM_PASSWORD — the basics.
  • VALHEIM_PUBLIC — 1 lists the server in the public community browser; 0 (default) keeps it private (join by IP or crossplay code — a password is still required either way).
  • Worlds are saved under /opt/valheim/data/worlds_local/. Admin/permitted/banned player lists live in /opt/valheim/data/ as adminlist.txt, permittedlist.txt, bannedlist.txt (SteamID64 per line). World modifiers (combat, raids, death penalty) can be added as -modifier launch args in /opt/valheim/start.sh.

Like Satisfactory, a Valheim dedicated server keeps the world running when empty, so onboot + Restart=always gives a 24/7 world.

Connect. In the main menu, pick a character, open the Join Game tab → Join by IP, and enter 192.168.1.16:2456 (the game port, not the query port). Enter the server password. For crossplay clients, use the Join Code printed in the server log (journalctl -u valheim).

Verify

From pve01, each service should be active and listening on its UDP port. Check one container (repeat for 201/202):

ssh root@192.168.1.4 'pct exec 200 -- systemctl is-active satisfactory'
ssh root@192.168.1.4 'pct exec 200 -- ss -lntup'

systemctl is-active prints active. ss -lntup lists both TCP and UDP listeners — Satisfactory binds *:7777 on udp and tcp plus *:8888 tcp (a udp-only check like ss -lunp misses the mandatory TCP API/reliable ports), Factorio binds *:34197 udp, and Valheim binds *:2456/*:2457 udp. The first SteamCMD install or the first Factorio map generation takes a few minutes; until it finishes the service may be restarting, which is expected.

A quick check from any LAN machine that the port answers:

nc -u -z -w2 192.168.1.14 7777; echo "exit=$?"

Update

  • Satisfactory / Valheim — re-run the install script (SteamCMD updates in place), or just systemctl restart satisfactory (its unit auto-updates via SteamCMD on every start; a failed update is non-fatal and it starts on the last-known-good build).
  • Factorio — re-run install-factorio.sh; it re-downloads the current stable headless build over the existing install and keeps your saves and server-settings.json.

Uninstall

Stop and destroy a container (this deletes its rootfs and the world saves inside it — back up first if you care about them):

ssh root@192.168.1.4 'pct stop 200 && pct destroy 200'

Troubleshooting

  • SteamCMD fails in the container (Satisfactory/Valheim) with a keyring or Failed to init SteamAPI error — the container is missing the keyctl feature. Confirm features: keyctl=1,nesting=1 in pct config <ctid>; re-provisioning sets it.
  • SteamCMD Failed to install app '1690800' (Missing configuration) (or (No subscription)) on the first install — a known SteamCMD bootstrap race, not a real fault. Anonymous login has already succeeded ("Connecting anonymously to Steam Public...OK"); SteamCMD just self-updates on that first run and has not cached the app's configuration yet. Re-run the exact same app_update — it succeeds once bootstrapped. The install scripts retry it automatically (up to 5 attempts); if you are driving steamcmd.sh by hand, simply run the command two or three more times. This is not a "missing config file" you need to create, and it is unrelated to the keyctl issue above.
  • Server not visible / players can't join — check the service is active, that the right port is bound (ss -lntup — Satisfactory needs its TCP ports, not just UDP), and that nothing on the LAN filters it. For play from outside the LAN, forward the game's ports on the router to the container IP.
  • Satisfactory shows "offline" in Server Manager right after install — the first SteamCMD download is still running; watch journalctl -u satisfactory -f until it finishes, then refresh.
  • Valheim service restart-loops — usually the password rule: at least 5 characters and not a substring of the server name. Fix VALHEIM_PASSWORD in /etc/valheim/valheim.env and restart.
  • Container can't resolve steamcdn/factorio.com — DNS. The container is set to resolve via 192.168.1.60; confirm pct exec <ctid> -- cat /etc/resolv.conf points there (see DNS).