working valheim checkpoint

This commit is contained in:
2026-09-20 23:45:00 -07:00
parent a981013d89
commit 02d485ddbf
3 changed files with 243 additions and 83 deletions
+188
View File
@@ -0,0 +1,188 @@
# Valheim Server StackScript
A Linode StackScript that turns a freshly created Debian 13 Linode into a
Valheim dedicated server. Running it as a StackScript means everything happens
as root during deployment, so no SSH session (and therefore no SSH key) is
needed to configure the machine.
It does four things, in order:
1. Installs Docker from Debian's own `docker.io` package.
2. Mounts the pre-existing Valheim volume (this mirrors `voldirections.txt`;
`mkfs.ext4` is **not** run because the filesystem already exists).
3. Creates the persistent `config/` and `data/` directories on the volume.
4. Starts the Valheim server container with those directories bind-mounted, so
worlds, configuration and backups survive destroying and recreating the
Linode.
## Instructions
1. Log in to the Linode Cloud Manager and open **StackScripts**.
2. Click **Create StackScript**.
3. Set **Label** to something like `valheim-server` and choose **Debian 13**
(or "Any") as the **Target Images**.
4. Paste the code block below into the **Script** editor.
5. Edit the server settings at the top of the script (name, world, password)
before saving.
6. Leave the **UDF** (User Defined Fields) section empty — the values are
hard-coded, so nothing needs to be entered at deploy time.
7. Click **Save**. The numeric ID of the StackScript appears in the URL and in
the StackScript list; copy it.
8. Put that ID in `create-linode.sh` as the `STACKSCRIPT_ID` value near the top
of the file.
After that, `create-linode.sh c <label>` passes the StackScript on the create
call and the Linode comes up as a running Valheim server with no further steps.
## Where things live on the volume
Everything the container needs to keep is under `/mnt/ValVol202609/valheim`:
| Host path | Container path | Contents |
| --- | --- | --- |
| `/mnt/ValVol202609/valheim/config` | `/config` | World saves, `adminlist.txt`, `bannedlist.txt`, `permittedlist.txt`, `backups/` |
| `/mnt/ValVol202609/valheim/data` | `/opt/valheim` | The ~1 GB downloaded server plus SteamCMD's depot cache |
Mounting `/opt/valheim` as well means a newly created Linode does not have to
re-download the server from Steam, which saves several minutes per instance.
The depot cache lives inside that same directory so it stays in sync with the
installation it belongs to.
Worlds are stored in `config/worlds_local/<WORLD_NAME>/` (older releases used a
single `<WORLD_NAME>.db`/`.fwl` pair directly in `config/worlds/`). To bring an
existing world along, copy it into the `worlds_local` directory on the volume
and set `WORLD_NAME` to match.
## Notes
- **Ports.** The container publishes `2456-2457/udp` (game and query). Linodes
have no inbound firewall by default, so nothing else is required. If you add
a Cloud Firewall, allow those two UDP ports.
- **Sizing.** The upstream project recommends a high-clocked 4 core / 8 GB
machine, which is exactly `g6-standard-4` — the type `create-linode.sh` uses.
- **First start.** The container downloads ~1 GB from Steam on first boot and
takes several minutes to become joinable.
- **Admin.** Once the server has started, `config/adminlist.txt` exists on the
volume. Put your SteamID64 in it to get in-game admin commands.
- **Backups.** The container writes hourly world backups to `config/backups/`
on the volume and prunes them after 3 days.
- **Updates.** The container checks for Valheim updates every 15 minutes and
restarts the server when one is found, but only while nobody is connected.
- **Re-running.** The script is safe to run again: the mount and the `fstab`
entry are only created if missing, and any previous container is removed
before the new one is started.
## StackScript
```bash
#!/bin/bash
# Linode StackScript: turn a fresh Debian 13 Linode into a Valheim dedicated
# server whose worlds, configuration and backups live on the pre-existing
# Valheim volume. Runs as root during deployment; no SSH access required.
set -euo pipefail
# ---------------------------------------------------------------------------
# Server settings - edit these before saving the StackScript.
# ---------------------------------------------------------------------------
SERVER_NAME="Valheim Server"
WORLD_NAME="Dedicated"
# Must be at least 5 characters, otherwise valheim_server.x86_64 refuses to
# start. Anything saved here is readable by anyone with access to the
# StackScript, so change it.
SERVER_PASS="changeme123"
# true lists the server in the in-game community browser.
SERVER_PUBLIC="true"
# ---------------------------------------------------------------------------
# Volume and container settings.
# ---------------------------------------------------------------------------
VOLUME_NAME="ValVol202609"
VOLUME_DEVICE="/dev/disk/by-id/scsi-0Linode_Volume_${VOLUME_NAME}"
VOLUME_MOUNT="/mnt/${VOLUME_NAME}"
# Everything the container has to keep across Linode rebuilds lives here.
VALHEIM_DIR="${VOLUME_MOUNT}/valheim"
CONFIG_DIR="${VALHEIM_DIR}/config"
DATA_DIR="${VALHEIM_DIR}/data"
CONTAINER_NAME="valheim-server"
# The upstream project moved to ghcr.io/community-valheim-tools/valheim-server.
# The Docker Hub image below is still published and can be swapped for it.
IMAGE="lloesche/valheim-server"
# ---------------------------------------------------------------------------
# 1. Install Docker.
# Debian's docker.io package is enough for a single `docker run` and avoids
# adding a third-party apt repository. ca-certificates is listed explicitly
# because --no-install-recommends would otherwise skip it and image pulls
# would fail TLS verification.
# ---------------------------------------------------------------------------
export DEBIAN_FRONTEND=noninteractive
apt-get update
apt-get install -y --no-install-recommends docker.io ca-certificates docker-cli
systemctl enable --now docker
# ---------------------------------------------------------------------------
# 2. Mount the Valheim volume.
# The volume is attached over the Linode API once the instance is running, so
# wait for the device to appear. 60 attempts x 5s = 5 minute ceiling.
# ---------------------------------------------------------------------------
for _ in $(seq 1 60); do
if [ -e "$VOLUME_DEVICE" ]; then
break
fi
echo "Waiting for ${VOLUME_DEVICE} to appear..."
sleep 5
done
if [ ! -e "$VOLUME_DEVICE" ]; then
echo "ERROR: ${VOLUME_DEVICE} never appeared. Aborting so that the world is not written to the instance's ephemeral disk." >&2
exit 1
fi
mkdir -p "$VOLUME_MOUNT"
if ! mountpoint -q "$VOLUME_MOUNT"; then
mount "$VOLUME_DEVICE" "$VOLUME_MOUNT"
fi
# Mount on every boot, without duplicating the line on re-runs. nofail keeps a
# missing volume from blocking boot; if the volume is not there the mountpoint
# silently becomes a directory on the instance's own disk, which is why the
# check above aborts rather than continuing.
if ! grep -q "$VOLUME_DEVICE" /etc/fstab; then
echo "${VOLUME_DEVICE} ${VOLUME_MOUNT} ext4 defaults,noatime,nofail 0 2" >> /etc/fstab
fi
# ---------------------------------------------------------------------------
# 3. Create the persistent directories on the volume.
# ---------------------------------------------------------------------------
mkdir -p "$CONFIG_DIR" "$DATA_DIR"
# ---------------------------------------------------------------------------
# 4. Start the Valheim server.
# --restart unless-stopped brings the server back after a reboot.
# --stop-timeout 120 gives the server time to save the world on shutdown.
# --cap-add=sys_nice lets the Steam library raise its own thread priority.
# ---------------------------------------------------------------------------
docker rm -f "$CONTAINER_NAME" >/dev/null 2>&1 || true
docker run -d \
--name "$CONTAINER_NAME" \
--cap-add=sys_nice \
--stop-timeout 120 \
--restart unless-stopped \
-p 2456-2457:2456-2457/udp \
-v "${CONFIG_DIR}:/config" \
-v "${DATA_DIR}:/opt/valheim" \
-e SERVER_NAME="$SERVER_NAME" \
-e WORLD_NAME="$WORLD_NAME" \
-e SERVER_PASS="$SERVER_PASS" \
-e SERVER_PUBLIC="$SERVER_PUBLIC" \
"$IMAGE"
echo "Valheim server started."
echo "The first start downloads ~1GB from Steam and takes several minutes."
echo "Follow progress with: docker logs -f $CONTAINER_NAME"
```
+55 -18
View File
@@ -26,7 +26,8 @@ VOLUME_MOUNT="/mnt/$VOLUME_NAME"
# StackScript that mounts the volume on first boot (see stackscript.md).
# Enter the numeric StackScript ID manually after creating it in the Linode UI.
STACKSCRIPT_ID="2225110"
#STACKSCRIPT_ID="2225110"
STACKSCRIPT_ID="2225140"
if [[ "$SCRIPT_ACTION" == "" || "$SCRIPT_ACTION" == "--help" || "$SCRIPT_ACTION" == "-?" ]]; then
echo "Linode helper script: create-linode.sh <action> <label> [args]"
@@ -38,6 +39,7 @@ if [[ "$SCRIPT_ACTION" == "" || "$SCRIPT_ACTION" == "--help" || "$SCRIPT_ACTION"
echo " create-linode.sh x <label> get <remote source> <local dest>"
echo " create-linode.sh x <label> put <local source> <remote dest>"
echo "Update record: create-linode.sh a <label> <subdomain>"
echo " (updates the existing A record, creates one if missing)"
echo "List: create-linode.sh l"
echo " (lists all Linodes, no label needed)"
fi
@@ -118,6 +120,17 @@ if [[ "$SCRIPT_ACTION" == "d" ]]; then
INSTANCE_ID=$(curl -s -H "Authorization: Bearer $LINODE_API_KEY" https://api.linode.com/v4/linode/instances | jq '.data[] | select(.label == "'$LINODE_LABEL'") | .id' | sed -e "s/\"//g")
echo "Destroying ID:$INSTANCE_ID Hit Ctrl-C in the next 10 seconds to cancel."
sleep 15
# Shut the Linode down cleanly before deleting it, so systemd stops the
# Docker container and the Valheim server gets the chance to save the world
# rather than being cut off mid-write.
echo "Shutting down ID:$INSTANCE_ID..."
curl -s -H "Authorization: Bearer $LINODE_API_KEY" \
-X POST https://api.linode.com/v4/linode/instances/$INSTANCE_ID/shutdown | jq .
# Give the shutdown time to finish before the instance is deleted.
sleep 30
curl -s -H "Authorization: Bearer $LINODE_API_KEY" \
-X DELETE https://api.linode.com/v4/linode/instances/$INSTANCE_ID
echo "$(date -Iminutes) --- Id:$INSTANCE_ID --- IP:$INSTANCE_IP Destroyed $LINODE_LABEL." >>$LOGFILE
@@ -154,30 +167,54 @@ if [[ "$SCRIPT_ACTION" == "a" ]]; then
INSTANCE_IP=$(curl -s -H "Authorization: Bearer $LINODE_API_KEY" https://api.linode.com/v4/linode/instances | jq '.data[] | select(.label == "'$LINODE_LABEL'") | .ipv4[]' | sed -e "s/\"//g")
NI_SUBDOMAIN=$3
DOMAIN_RECORDS=$(curl -s -H "Authorization: Bearer $LINODE_API_KEY" \
https://api.linode.com/v4/domains/$WHICH_DOMAIN/records)
echo "Previous value:"
curl -s -H "Authorization: Bearer $LINODE_API_KEY" \
https://api.linode.com/v4/domains/$WHICH_DOMAIN/records | jq .
echo "$DOMAIN_RECORDS" | jq .
sleep 3
# Update the A record that already exists for this subdomain rather than
# adding another one; a record is only created when there is nothing to
# update. DNS names are case-insensitive, so compare them that way.
RECORD_IDS=$(echo "$DOMAIN_RECORDS" | jq -r --arg name "$NI_SUBDOMAIN" \
'.data[] | select(.type == "A" and ((.name // "") | ascii_downcase) == ($name | ascii_downcase)) | .id')
RECORD_ID=$(echo "$RECORD_IDS" | head -n 1)
DUPLICATE_IDS=$(echo "$RECORD_IDS" | tail -n +2)
echo "Updating records with ip $INSTANCE_IP..."
#ni7ne:
curl -s -H "Content-Type: application/json" \
-H "Authorization: Bearer $LINODE_API_KEY" \
-X POST -d '{
"type": "A",
"name": "'$NI_SUBDOMAIN'",
"target": "'$INSTANCE_IP'",
"priority": 0,
"weight": 0,
"port": 0,
"service": null,
"protocol": null,
"ttl_sec": 14400,
"tag": null
}' \
https://api.linode.com/v4/domains/$WHICH_DOMAIN/records | jq .
if [[ -n "$RECORD_ID" ]]; then
curl -s -H "Content-Type: application/json" \
-H "Authorization: Bearer $LINODE_API_KEY" \
-X PUT -d '{
"type": "A",
"name": "'$NI_SUBDOMAIN'",
"target": "'$INSTANCE_IP'",
"ttl_sec": 14400
}' \
https://api.linode.com/v4/domains/$WHICH_DOMAIN/records/$RECORD_ID | jq .
# Duplicates left behind by earlier runs would keep resolving to an old
# IP, so remove everything except the record that was just updated.
for DUPLICATE_ID in $DUPLICATE_IDS; do
echo "Deleting duplicate A record $DUPLICATE_ID for $NI_SUBDOMAIN..."
curl -s -H "Authorization: Bearer $LINODE_API_KEY" \
-X DELETE https://api.linode.com/v4/domains/$WHICH_DOMAIN/records/$DUPLICATE_ID
done
else
curl -s -H "Content-Type: application/json" \
-H "Authorization: Bearer $LINODE_API_KEY" \
-X POST -d '{
"type": "A",
"name": "'$NI_SUBDOMAIN'",
"target": "'$INSTANCE_IP'",
"ttl_sec": 14400
}' \
https://api.linode.com/v4/domains/$WHICH_DOMAIN/records | jq .
fi
echo "Update complete."
-65
View File
@@ -1,65 +0,0 @@
# Valheim Volume StackScript
A Linode StackScript that mounts the pre-existing Valheim volume on first boot.
Running it as a StackScript means the mount happens as root during deployment,
so no SSH session (and therefore no SSH key) is needed to configure it.
This mirrors the steps in `voldirections.txt`. The filesystem (`mkfs.ext4`) is
**not** created here, because that step has already been completed on the
volume; the script only creates the mountpoint, mounts the device, and adds the
`/etc/fstab` entry.
## Instructions
1. Log in to the Linode Cloud Manager and open **StackScripts**.
2. Click **Create StackScript**.
3. Set **Label** to something like `valheim-volume-mount` and choose
**Debian 13** (or "Any") as the **Target Images**.
4. Paste the code block below into the **Script** editor.
5. Leave the **UDF** (User Defined Fields) section empty — the values are
hard-coded, so nothing needs to be entered at deploy time.
6. Click **Save**. The numeric ID of the StackScript appears in the URL and in
the StackScript list; copy it.
7. Put that ID in `create-linode.sh` as the `STACKSCRIPT_ID` value near the top
of the file.
After that, `create-linode.sh c <label>` passes the StackScript on the create
call and the volume is mounted automatically on first boot.
## StackScript
```bash
#!/bin/bash
# Linode StackScript: mount the pre-existing Valheim volume on first boot.
# Runs as root during deployment; no SSH access required.
set -euo pipefail
# The volume already has an ext4 filesystem (see voldirections.txt), so only
# the mountpoint, the mount, and the fstab entry are handled here.
VOLUME_NAME="ValVol202609"
VOLUME_DEVICE="/dev/disk/by-id/scsi-0Linode_Volume_${VOLUME_NAME}"
VOLUME_MOUNT="/mnt/${VOLUME_NAME}"
# The volume is attached by the API after deployment starts, so wait for the
# device to show up before trying to mount it.
for _ in $(seq 1 30); do
[ -e "$VOLUME_DEVICE" ] && break
sleep 5
done
if [ ! -e "$VOLUME_DEVICE" ]; then
echo "Volume device ${VOLUME_DEVICE} did not appear; skipping mount." >&2
exit 1
fi
mkdir -p "$VOLUME_MOUNT"
mount "$VOLUME_DEVICE" "$VOLUME_MOUNT"
# Mount on every boot, without duplicating the line on re-runs.
grep -q "$VOLUME_DEVICE" /etc/fstab || \
echo "${VOLUME_DEVICE} ${VOLUME_MOUNT} ext4 defaults,noatime,nofail 0 2" >> /etc/fstab
echo "Mounted ${VOLUME_DEVICE} at ${VOLUME_MOUNT}."
```